From 27d659118fae86f2ad8e5ced7715cd1588631ab2 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 30 Sep 2026 19:00:10 -0400 Subject: [PATCH 1/2] fix(desktop updates): Linux package installs update via their package manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/settings/general/General.tsx | 140 +++++++++++++----- src/app/hooks/useTauriUpdater.ts | 21 ++- src/app/pages/client/ClientNonUIFeatures.tsx | 16 +- src/app/utils/updateErrors.test.ts | 32 ++++ src/app/utils/updateErrors.ts | 36 +++++ 5 files changed, 203 insertions(+), 42 deletions(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 898c1a22d..4103ebfaa 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -112,7 +112,13 @@ import { import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine'; import { SequenceCardStyle } from '../styles.css'; import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater'; -import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors'; +import { + describeUpdateError, + isPackageManagedError, + manualDownloadUrl, + packageUpdateHelp, +} from '../../../utils/updateErrors'; +import { copyToClipboard } from '../../../utils/dom'; import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri'; import { useKeychainMirrorStatus } from '../../../state/keychainMirror'; import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys'; @@ -2794,12 +2800,76 @@ function updateProgressText(progress: UpdateProgress | undefined): string { return `Downloading update… ${pct}% (${formatMb(downloaded)} of ${formatMb(total)})${attempt}`; } -function AppUpdates() { - const { isTauri, status, check, install } = useTauriUpdater(); - if (!isTauri) return null; +/** + * A Linux package install is updated with its package manager (the in-app + * updater can't write to /usr/bin): the command, Copy and a download link. + */ +function PackageUpdate({ help }: { help: NonNullable> }) { + const [copied, setCopied] = useState<'no' | 'yes' | 'failed'>('no'); + const copy = () => { + const text = help.command ?? ''; + if (!navigator.clipboard) { + copyToClipboard(text); + setCopied('yes'); + return; + } + navigator.clipboard.writeText(text).then( + () => setCopied('yes'), + () => setCopied('failed'), + ); + }; + let copyLabel = 'Copy command'; + if (copied === 'yes') copyLabel = 'Copied'; + else if (copied === 'failed') copyLabel = 'Copy failed: select it above'; + return ( + + + Lotus Chat was installed as a system package, so update it the same way + {help.command ? ' (your chats and settings are kept):' : '.'} + + {help.command && ( + + {help.command} + + )} + + {help.command && ( + + )} + + + + ); +} - const description = - status.state === 'checking' +function AppUpdates() { + const { isTauri, status, check, install, installKind } = useTauriUpdater(); + if (!isTauri) return null; + const packageHelp = packageUpdateHelp(installKind); + const packageUpdate = + !!packageHelp && + (status.state === 'available' || + (status.state === 'error' && isPackageManagedError(status.message))); + + const description = packageUpdate + ? status.state === 'available' + ? `Update available: v${status.version}` + : 'An update is available.' + : status.state === 'checking' ? 'Checking for updates...' : status.state === 'up-to-date' ? 'Lotus Chat is up to date.' @@ -2816,43 +2886,43 @@ function AppUpdates() { else check(); }; - const after = - status.state === 'available' ? ( - + ) : status.state === 'checking' || status.state === 'installing' ? ( + + ) : status.state === 'error' ? ( + + - ) : status.state === 'checking' || status.state === 'installing' ? ( - - ) : status.state === 'error' ? ( - - - {status.phase !== 'check' && ( - - )} - - ) : ( - - ); + )} + + ) : ( + + ); return ( App Updates - {status.state === 'error' && ( + {packageUpdate && packageHelp && } + {status.state === 'error' && !packageUpdate && ( Details: {status.message} diff --git a/src/app/hooks/useTauriUpdater.ts b/src/app/hooks/useTauriUpdater.ts index 02c3ad90d..da1d91218 100644 --- a/src/app/hooks/useTauriUpdater.ts +++ b/src/app/hooks/useTauriUpdater.ts @@ -1,7 +1,7 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import { atom, useAtom, useSetAtom } from 'jotai'; import { useTauriEvent } from './useTauri'; -import { UpdatePhase, parseUpdateError } from '../utils/updateErrors'; +import { InstallKind, UpdatePhase, parseUpdateError, toInstallKind } from '../utils/updateErrors'; type TauriInternals = { invoke: (cmd: string, args?: Record) => Promise }; const tauriInvoke = (): TauriInternals['invoke'] | undefined => @@ -36,6 +36,11 @@ export type UpdateFailure = { phase: UpdatePhase; message: string }; // mid-request by the resolve/reject below, so nothing gets stuck. const updateStatusAtom = atom({ 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(undefined); +let installKindRequested = false; + /** * Mirror native download progress into the status. Mounted once (in * TauriUpdateFeature) so the listener isn't duplicated per consumer. @@ -50,6 +55,16 @@ export function useTauriUpdateProgress(): void { 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(); @@ -89,5 +104,5 @@ export function useTauriUpdater() { } }, [setStatus]); - return { isTauri, status, check, install }; + return { isTauri, status, check, install, installKind: installKind ?? 'in-app' }; } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b0c1b6d0c..ae7997ed5 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -69,6 +69,7 @@ import { dismissToastAtom, toastQueueAtom } from '../../state/toast'; import { useReminders } from '../../hooks/useReminders'; import { getRoomRetentionMs, isExpired } from '../../utils/retention'; import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater'; +import { settingsRequestAtom } from '../../state/settingsRequest'; import { isNetworkUpdateError } from '../../utils/updateErrors'; import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri'; import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt'; @@ -913,7 +914,8 @@ const UPDATE_PROGRESS_TOAST = 'tauri-update-progress'; const UPDATE_FAILED_TOAST = 'tauri-update-failed'; function TauriUpdateFeature() { - const { isTauri, status, check, install } = useTauriUpdater(); + const { isTauri, status, check, install, installKind } = useTauriUpdater(); + const requestSettings = useSetAtom(settingsRequestAtom); useTauriUpdateProgress(); const setToast = useSetAtom(toastQueueAtom); const dismissToast = useSetAtom(dismissToastAtom); @@ -972,18 +974,24 @@ function TauriUpdateFeature() { if (status.state !== 'available') return; if (firedRef.current === status.version) return; firedRef.current = status.version; + // A Linux package install can't be updated in place: point at the + // package-manager command in Settings instead of an install that fails. + const viaPackage = installKind !== 'in-app'; setToast({ id: `tauri-update-${status.version}`, displayName: '⬆ Update Available', - body: `Lotus Chat ${status.version} is ready. Click to install and restart.`, + body: viaPackage + ? `Lotus Chat ${status.version} is available. Click for the command to update it with your package manager.` + : `Lotus Chat ${status.version} is ready. Click to install and restart.`, roomName: 'System', roomId: '', onClick: () => { - installFromToast(); + if (viaPackage) requestSettings('general'); + else installFromToast(); }, sticky: true, }); - }, [status, setToast, installFromToast]); + }, [status, setToast, installFromToast, installKind, requestSettings]); // [cinny-desktop #6] Mirror a pending update into the tray ("Restart to // update" + tooltip) so a dismissed toast isn't the only reminder. Kept while diff --git a/src/app/utils/updateErrors.test.ts b/src/app/utils/updateErrors.test.ts index 209f2e5a9..81ea8569b 100644 --- a/src/app/utils/updateErrors.test.ts +++ b/src/app/utils/updateErrors.test.ts @@ -55,3 +55,35 @@ test('manual download link: Windows gets the installer, others the release page' ); assert.equal(manualDownloadUrl('Mozilla/5.0 (X11; Linux x86_64)'), MANUAL_DOWNLOAD_URL.other); }); + +test('install kinds: anything unknown keeps the in-app updater', async () => { + const { toInstallKind } = await import('./updateErrors'); + assert.equal(toInstallKind('pacman'), 'pacman'); + assert.equal(toInstallKind('deb'), 'deb'); + assert.equal(toInstallKind('manual'), 'manual'); + assert.equal(toInstallKind('in-app'), 'in-app'); + assert.equal(toInstallKind(undefined), 'in-app', 'older desktop build without the command'); + assert.equal(toInstallKind('rpm'), 'in-app'); +}); + +test('package installs get their package manager’s command, not an Install button', async () => { + const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors'); + const pacman = packageUpdateHelp('pacman')!; + assert.equal( + pacman.command, + 'sudo pacman -U https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst', + ); + assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/); + const deb = packageUpdateHelp('deb')!; + assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/); + assert.equal(packageUpdateHelp('manual')?.command, undefined); + assert.equal(packageUpdateHelp('in-app'), undefined); + assert.equal( + isPackageManagedError('package-managed (pacman): update Lotus Chat with your package manager'), + true, + ); + assert.equal( + isPackageManagedError('Permission denied (os error 13) at path "/usr/bin/tauri_current_app"'), + false, + ); +}); diff --git a/src/app/utils/updateErrors.ts b/src/app/utils/updateErrors.ts index c671db8f4..e36b8de31 100644 --- a/src/app/utils/updateErrors.ts +++ b/src/app/utils/updateErrors.ts @@ -47,3 +47,39 @@ export const describeUpdateError = (phase: UpdatePhase, message: string): string } return 'The update downloaded but couldn’t be installed. Download the installer yourself and run it; your chats and settings are kept.'; }; + +/** + * How this desktop install gets updated (cinny-desktop `update_install_kind`). + * Linux package installs can't be replaced in place by the in-app updater + * (it tried to write into /usr/bin: "Permission denied (os error 13)"), so + * they get their package manager's command instead of an Install button. + */ +export type InstallKind = 'in-app' | 'pacman' | 'deb' | 'manual'; + +const RELEASE_DOWNLOAD = + 'https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest'; + +export const toInstallKind = (value: unknown): InstallKind => + value === 'pacman' || value === 'deb' || value === 'manual' ? value : 'in-app'; + +export type PackageUpdateHelp = { command?: string; url: string; download: string }; + +export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => { + if (kind === 'pacman') { + const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`; + return { command: `sudo pacman -U ${url}`, url, download: 'Download package' }; + } + if (kind === 'deb') { + const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`; + return { + command: `curl -LO ${url} && sudo apt install ./LotusChat-x86_64.deb`, + url, + download: 'Download package', + }; + } + if (kind === 'manual') return { url: MANUAL_DOWNLOAD_URL.other, download: 'Open downloads' }; + return undefined; +}; + +/** The native side refuses an in-app install on a package install. */ +export const isPackageManagedError = (message: string): boolean => /package-managed/.test(message); -- 2.47.3 From 6acdd439d5f18e0f7c4e6fecc3eba94366301071 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Wed, 30 Sep 2026 19:57:12 -0400 Subject: [PATCH 2/2] fix(desktop updates): pacman command downloads first, installs the local file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sudo pacman -U ` also fetches `.sig` and failed (404) on CachyOS: remote packages fall under RemoteFileSigLevel (signature required) and we don't sign the package. A downloaded file installs under LocalFileSigLevel (optional): `curl -LO && sudo pacman -U ./…`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/utils/updateErrors.test.ts | 3 ++- src/app/utils/updateErrors.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/utils/updateErrors.test.ts b/src/app/utils/updateErrors.test.ts index 81ea8569b..85574044d 100644 --- a/src/app/utils/updateErrors.test.ts +++ b/src/app/utils/updateErrors.test.ts @@ -69,9 +69,10 @@ test('install kinds: anything unknown keeps the in-app updater', async () => { test('package installs get their package manager’s command, not an Install button', async () => { const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors'); const pacman = packageUpdateHelp('pacman')!; + // Not `pacman -U `: that also wants `.sig`, which we don't publish. assert.equal( pacman.command, - 'sudo pacman -U https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst', + 'curl -LO https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst', ); assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/); const deb = packageUpdateHelp('deb')!; diff --git a/src/app/utils/updateErrors.ts b/src/app/utils/updateErrors.ts index e36b8de31..e45201e22 100644 --- a/src/app/utils/updateErrors.ts +++ b/src/app/utils/updateErrors.ts @@ -67,7 +67,14 @@ export type PackageUpdateHelp = { command?: string; url: string; download: strin export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => { if (kind === 'pacman') { const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`; - return { command: `sudo pacman -U ${url}`, url, download: 'Download package' }; + // Download first, then install the local file: `pacman -U ` also + // fetches `.sig` and fails without it (we don't sign packages), + // while a local file falls under LocalFileSigLevel (signature optional). + return { + command: `curl -LO ${url} && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst`, + url, + download: 'Download package', + }; } if (kind === 'deb') { const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`; -- 2.47.3