Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 6acdd439d5 fix(desktop updates): pacman command downloads first, installs the local file
CI / Build & Quality Checks (pull_request) Successful in 1m44s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m38s
`sudo pacman -U <url>` also fetches `<url>.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 <url> && sudo pacman -U ./…`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 19:57:12 -04:00
Lotus CI 9aec3691ac Merge remote-tracking branch 'origin/lotus' into linux-package-updates
CI / Build & Quality Checks (pull_request) Successful in 1m38s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Canceled after 2m32s
2026-09-30 19:41:39 -04:00
jared 4c36c03066 Merge pull request 'deps: brace-expansion 1.1.21 (unblocks CI audit)' (#257) from audit-brace-expansion into lotus
CI / Build & Quality Checks (push) Successful in 1m37s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 9m31s
Merge pull request #257
2026-09-30 19:41:38 -04:00
Lotus CIandClaude Opus 5.5 fbdac30d18 deps: brace-expansion 1.1.18 → 1.1.21 (GHSA-q2hr-2g5m-vwhr and two related DoS advisories)
CI / Build & Quality Checks (pull_request) Successful in 1m44s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m56s
npm audit --omit=dev started failing every PR: brace-expansion <=1.1.20
(via @eslint/eslintrc → minimatch 3) has three high-severity DoS
advisories. Lockfile-only patch bump; nothing else changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 19:04:47 -04:00
Lotus CIandClaude Opus 5.5 27d659118f fix(desktop updates): Linux package installs update via their package manager
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
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
jared 747400ea25 Merge pull request 'Homeserver status banner from Uptime Kuma (#124)' (#255) from server-status-banner into lotus
CI / Build & Quality Checks (push) Successful in 4m10s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 14m59s
Merge pull request #255: homeserver status banner from Uptime Kuma (#124)
2026-09-29 12:29:00 -04:00
6 changed files with 214 additions and 45 deletions
+3 -3
View File
@@ -5026,9 +5026,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
+79 -9
View File
@@ -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<ReturnType<typeof packageUpdateHelp>> }) {
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 (
<Box direction="Column" gap="200">
<Text size="T200">
Lotus Chat was installed as a system package, so update it the same way
{help.command ? ' (your chats and settings are kept):' : '.'}
</Text>
{help.command && (
<Text
size="T200"
style={{ fontFamily: 'monospace', wordBreak: 'break-all', userSelect: 'all' }}
>
{help.command}
</Text>
)}
<Box gap="200" wrap="Wrap">
{help.command && (
<Button size="300" radii="300" variant="Secondary" onClick={copy}>
<Text size="B300">{copyLabel}</Text>
</Button>
)}
<Button
size="300"
radii="300"
variant="Secondary"
fill="None"
outlined
onClick={() => window.open(help.url, '_blank')}
>
<Text size="B300">{help.download}</Text>
</Button>
</Box>
</Box>
);
}
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,8 +2886,7 @@ function AppUpdates() {
else check();
};
const after =
status.state === 'available' ? (
const after = packageUpdate ? undefined : status.state === 'available' ? (
<Button size="300" radii="300" onClick={() => install()}>
<Text size="B300">Install &amp; Restart</Text>
</Button>
@@ -2852,7 +2921,8 @@ function AppUpdates() {
<Text size="L400">App Updates</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Check for Updates" description={description} after={after} />
{status.state === 'error' && (
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
{status.state === 'error' && !packageUpdate && (
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
Details: {status.message}
</Text>
+18 -3
View File
@@ -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<string, unknown>) => Promise<unknown> };
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<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.
@@ -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' };
}
+12 -4
View File
@@ -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
+33
View File
@@ -55,3 +55,36 @@ 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')!;
// Not `pacman -U <url>`: that also wants `<url>.sig`, which we don't publish.
assert.equal(
pacman.command,
'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')!;
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,
);
});
+43
View File
@@ -47,3 +47,46 @@ 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`;
// Download first, then install the local file: `pacman -U <url>` also
// fetches `<url>.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`;
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);