Files
cinny/src/app/hooks/useTauriUpdater.ts
T
Lotus CIandClaude Opus 5.5 27d659118f
CI / Build & Quality Checks (pull_request) Failing after 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
fix(desktop updates): Linux package installs update via their package manager
Reported on CachyOS: "Check for Updates → The update downloaded but
couldn't be installed … Permission denied (os error 13) at path
/usr/bin/tauri_current_app…". The app was installed from the Arch package;
Tauri's Linux updater can only replace an AppImage.

With cinny-desktop's new update_install_kind command:
- pacman / deb installs: the toast says the update is available and opens
  Settings → General → App Updates, which shows the package-manager command
  (`sudo pacman -U …pkg.tar.zst`, or the .deb + `sudo apt install`) with
  Copy command and Download package — no Install & Restart that can't work.
  "Copied" only when the clipboard write actually succeeded.
- other distros: a link to the downloads page.
- Windows, AppImage, and desktop builds without the command: unchanged
  in-app update.
- a native "package-managed" refusal shows the same help.

Tests: unit (kinds, commands, refusal detection); in the real client with a
simulated desktop bridge: pacman → toast + Settings command/buttons,
install never attempted; older desktop → in-app flow as before. Unit 1321,
Playwright 26 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 19:00:10 -04:00

109 lines
4.2 KiB
TypeScript

import { useCallback, useEffect } from 'react';
import { atom, useAtom, useSetAtom } from 'jotai';
import { useTauriEvent } from './useTauri';
import { InstallKind, UpdatePhase, parseUpdateError, toInstallKind } from '../utils/updateErrors';
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
/** Detail of the native `lotus-update-progress` event (see lib.rs `install_update`). */
export type UpdateProgress =
| {
phase: 'downloading';
attempt: number;
maxAttempts: number;
downloaded: number;
total: number | null;
}
| { phase: 'retrying'; attempt: number; maxAttempts: number; waitSecs: number; error: string }
| { phase: 'installing' };
export type UpdateStatus =
| { state: 'idle' }
| { state: 'checking' }
| { state: 'up-to-date' }
| { state: 'available'; version: string }
| { state: 'installing'; progress?: UpdateProgress }
| { state: 'error'; phase: UpdatePhase; message: string };
export type UpdateFailure = { phase: UpdatePhase; message: string };
// Module-level so the result of a manual "Check for updates" survives closing
// and reopening Settings (it used to be component state, so the "update
// available" answer was thrown away with the tab and had to be re-checked).
// A transient 'checking'/'installing' state is reset if the tab unmounts
// mid-request by the resolve/reject below, so nothing gets stuck.
const updateStatusAtom = atom<UpdateStatus>({ state: 'idle' });
// How this install gets updated; asked once. A desktop build without the
// command (older than it) rejects the call: keep the old in-app behaviour.
const installKindAtom = atom<InstallKind | undefined>(undefined);
let installKindRequested = false;
/**
* Mirror native download progress into the status. Mounted once (in
* TauriUpdateFeature) so the listener isn't duplicated per consumer.
*/
export function useTauriUpdateProgress(): void {
const setStatus = useSetAtom(updateStatusAtom);
useTauriEvent<UpdateProgress>('lotus-update-progress', (progress) =>
setStatus((prev) => (prev.state === 'installing' ? { state: 'installing', progress } : prev)),
);
}
export function useTauriUpdater() {
const isTauri = !!tauriInvoke();
const [status, setStatus] = useAtom(updateStatusAtom);
const [installKind, setInstallKind] = useAtom(installKindAtom);
useEffect(() => {
const invoke = tauriInvoke();
if (!invoke || installKindRequested) return;
installKindRequested = true;
invoke('update_install_kind')
.then((kind) => setInstallKind(toInstallKind(kind)))
.catch(() => setInstallKind('in-app'));
}, [setInstallKind]);
const check = useCallback(async () => {
const invoke = tauriInvoke();
if (!invoke) return;
setStatus({ state: 'checking' });
try {
const result = (await invoke('check_for_update')) as { available: boolean; version?: string };
setStatus(
result.available && result.version
? { state: 'available', version: result.version }
: { state: 'up-to-date' },
);
} catch (e) {
setStatus({ state: 'error', ...parseUpdateError(String(e), 'check') });
}
}, [setStatus]);
/**
* Resolves `undefined` when nothing needed installing (a successful install
* restarts the app instead), or the failure.
*/
const install = useCallback(async (): Promise<UpdateFailure | undefined> => {
const invoke = tauriInvoke();
if (!invoke) return undefined;
setStatus({ state: 'installing' });
try {
await invoke('install_update');
// On a successful install the native side restarts the app, so this
// resolve is only reached when nothing was installed (no update found) —
// don't leave the UI stuck on "installing".
setStatus({ state: 'up-to-date' });
return undefined;
} catch (e) {
const failure = parseUpdateError(String(e), 'download');
setStatus({ state: 'error', ...failure });
return failure;
}
}, [setStatus]);
return { isTauri, status, check, install, installKind: installKind ?? 'in-app' };
}