feat(desktop): update failures say what happened and what to do
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Build & Quality Checks (push) Canceled after 53s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Build & Quality Checks (push) Canceled after 53s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
A friend's update failed ten times in a row ("Update check failed: error
sending request for url (…nsis.zip)") before the 11th went through, and
he didn't know what to do. The label was also wrong: the check had
worked; the download failed.
- Progress: "Downloading update… 28% (14.3 MB of 49.9 MB)", "The update
server didn't respond. Trying again in 3 s (attempt 2 of 4)…", from the
native `lotus-update-progress` events (cinny-desktop retries itself).
- Failures name the step (check / download / install, from the native
error prefix) in plain language, with Try again and a Download
installer button (Windows: the setup .exe; else the release page), and
the raw error under "Details".
- Installing from the update toast now shows a "Downloading update"
toast, and on failure a sticky "Update didn't install" toast that
retries on click and points at Settings → General → App Updates.
Before, the toast vanished and the failure was only visible in Settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
23649f1255
commit
568f218fe9
@@ -110,7 +110,8 @@ import {
|
|||||||
} from '../../../utils/translation/langUtils';
|
} from '../../../utils/translation/langUtils';
|
||||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
|
import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors';
|
||||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||||
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
|
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
|
||||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||||
@@ -2682,6 +2683,22 @@ function Messages() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatMb = (bytes: number): string => `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||||
|
|
||||||
|
function updateProgressText(progress: UpdateProgress | undefined): string {
|
||||||
|
if (!progress) return 'Checking for the update…';
|
||||||
|
if (progress.phase === 'installing') return 'Installing — Lotus Chat will restart in a moment…';
|
||||||
|
if (progress.phase === 'retrying') {
|
||||||
|
return `The update server didn’t respond. Trying again in ${progress.waitSecs} s (attempt ${progress.attempt + 1} of ${progress.maxAttempts})…`;
|
||||||
|
}
|
||||||
|
const attempt =
|
||||||
|
progress.attempt > 1 ? ` (attempt ${progress.attempt} of ${progress.maxAttempts})` : '';
|
||||||
|
const { downloaded, total } = progress;
|
||||||
|
if (!total) return `Downloading update… ${formatMb(downloaded)}${attempt}`;
|
||||||
|
const pct = Math.min(100, Math.floor((downloaded / total) * 100));
|
||||||
|
return `Downloading update… ${pct}% (${formatMb(downloaded)} of ${formatMb(total)})${attempt}`;
|
||||||
|
}
|
||||||
|
|
||||||
function AppUpdates() {
|
function AppUpdates() {
|
||||||
const { isTauri, status, check, install } = useTauriUpdater();
|
const { isTauri, status, check, install } = useTauriUpdater();
|
||||||
if (!isTauri) return null;
|
if (!isTauri) return null;
|
||||||
@@ -2694,18 +2711,41 @@ function AppUpdates() {
|
|||||||
: status.state === 'available'
|
: status.state === 'available'
|
||||||
? `Update available: v${status.version}`
|
? `Update available: v${status.version}`
|
||||||
: status.state === 'installing'
|
: status.state === 'installing'
|
||||||
? 'Installing update, the app will restart shortly...'
|
? updateProgressText(status.progress)
|
||||||
: status.state === 'error'
|
: status.state === 'error'
|
||||||
? `Update check failed: ${status.message}`
|
? describeUpdateError(status.phase, status.message)
|
||||||
: 'Check for a new version of Lotus Chat.';
|
: 'Check for a new version of Lotus Chat.';
|
||||||
|
|
||||||
|
const retry = () => {
|
||||||
|
if (status.state === 'error' && status.phase !== 'check') install();
|
||||||
|
else check();
|
||||||
|
};
|
||||||
|
|
||||||
const after =
|
const after =
|
||||||
status.state === 'available' ? (
|
status.state === 'available' ? (
|
||||||
<Button size="300" radii="300" onClick={install}>
|
<Button size="300" radii="300" onClick={() => install()}>
|
||||||
<Text size="B300">Install & Restart</Text>
|
<Text size="B300">Install & Restart</Text>
|
||||||
</Button>
|
</Button>
|
||||||
) : status.state === 'checking' || status.state === 'installing' ? (
|
) : status.state === 'checking' || status.state === 'installing' ? (
|
||||||
<Spinner variant="Secondary" size="200" />
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
) : status.state === 'error' ? (
|
||||||
|
<Box gap="200" wrap="Wrap" justifyContent="End">
|
||||||
|
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
|
||||||
|
<Text size="B300">Try again</Text>
|
||||||
|
</Button>
|
||||||
|
{status.phase !== 'check' && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="None"
|
||||||
|
outlined
|
||||||
|
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
|
||||||
|
>
|
||||||
|
<Text size="B300">Download installer</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
||||||
<Text size="B300">Check</Text>
|
<Text size="B300">Check</Text>
|
||||||
@@ -2717,6 +2757,11 @@ function AppUpdates() {
|
|||||||
<Text size="L400">App Updates</Text>
|
<Text size="L400">App Updates</Text>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile title="Check for Updates" description={description} after={after} />
|
<SettingTile title="Check for Updates" description={description} after={after} />
|
||||||
|
{status.state === 'error' && (
|
||||||
|
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||||
|
Details: {status.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { atom, useAtom } from 'jotai';
|
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||||
|
import { useTauriEvent } from './useTauri';
|
||||||
|
import { UpdatePhase, parseUpdateError } from '../utils/updateErrors';
|
||||||
|
|
||||||
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||||
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
|
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
|
||||||
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
|
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
|
||||||
|
|
||||||
type UpdateStatus =
|
/** 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: 'idle' }
|
||||||
| { state: 'checking' }
|
| { state: 'checking' }
|
||||||
| { state: 'up-to-date' }
|
| { state: 'up-to-date' }
|
||||||
| { state: 'available'; version: string }
|
| { state: 'available'; version: string }
|
||||||
| { state: 'installing' }
|
| { state: 'installing'; progress?: UpdateProgress }
|
||||||
| { state: 'error'; message: string };
|
| { 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
|
// 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
|
// and reopening Settings (it used to be component state, so the "update
|
||||||
@@ -20,6 +36,17 @@ type UpdateStatus =
|
|||||||
// mid-request by the resolve/reject below, so nothing gets stuck.
|
// mid-request by the resolve/reject below, so nothing gets stuck.
|
||||||
const updateStatusAtom = atom<UpdateStatus>({ state: 'idle' });
|
const updateStatusAtom = atom<UpdateStatus>({ state: 'idle' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export function useTauriUpdater() {
|
||||||
const isTauri = !!tauriInvoke();
|
const isTauri = !!tauriInvoke();
|
||||||
const [status, setStatus] = useAtom(updateStatusAtom);
|
const [status, setStatus] = useAtom(updateStatusAtom);
|
||||||
@@ -36,22 +63,29 @@ export function useTauriUpdater() {
|
|||||||
: { state: 'up-to-date' },
|
: { state: 'up-to-date' },
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus({ state: 'error', message: String(e) });
|
setStatus({ state: 'error', ...parseUpdateError(String(e), 'check') });
|
||||||
}
|
}
|
||||||
}, [setStatus]);
|
}, [setStatus]);
|
||||||
|
|
||||||
const install = useCallback(async () => {
|
/**
|
||||||
|
* 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();
|
const invoke = tauriInvoke();
|
||||||
if (!invoke) return;
|
if (!invoke) return undefined;
|
||||||
setStatus({ state: 'installing' });
|
setStatus({ state: 'installing' });
|
||||||
try {
|
try {
|
||||||
await invoke('install_update');
|
await invoke('install_update');
|
||||||
// On a successful install the native side calls app.restart(), so this
|
// On a successful install the native side restarts the app, so this
|
||||||
// resolve is only reached when nothing was installed (no update found) —
|
// resolve is only reached when nothing was installed (no update found) —
|
||||||
// don't leave the UI stuck on "installing".
|
// don't leave the UI stuck on "installing".
|
||||||
setStatus({ state: 'up-to-date' });
|
setStatus({ state: 'up-to-date' });
|
||||||
|
return undefined;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus({ state: 'error', message: String(e) });
|
const failure = parseUpdateError(String(e), 'download');
|
||||||
|
setStatus({ state: 'error', ...failure });
|
||||||
|
return failure;
|
||||||
}
|
}
|
||||||
}, [setStatus]);
|
}, [setStatus]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Icons } from 'folds';
|
||||||
import {
|
import {
|
||||||
ClientEvent,
|
ClientEvent,
|
||||||
ClientEventHandlerMap,
|
ClientEventHandlerMap,
|
||||||
@@ -62,10 +63,11 @@ import {
|
|||||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||||
import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { toastQueueAtom } from '../../state/toast';
|
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
||||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
|
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||||
import { invokeTauri, isTauri as isTauriApp } from '../../hooks/useTauri';
|
import { invokeTauri, isTauri as isTauriApp } from '../../hooks/useTauri';
|
||||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||||
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||||
@@ -887,11 +889,50 @@ function RetentionSweeper() {
|
|||||||
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
|
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
|
||||||
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
|
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
|
||||||
|
|
||||||
|
const UPDATE_PROGRESS_TOAST = 'tauri-update-progress';
|
||||||
|
const UPDATE_FAILED_TOAST = 'tauri-update-failed';
|
||||||
|
|
||||||
function TauriUpdateFeature() {
|
function TauriUpdateFeature() {
|
||||||
const { isTauri, status, check, install } = useTauriUpdater();
|
const { isTauri, status, check, install } = useTauriUpdater();
|
||||||
|
useTauriUpdateProgress();
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const dismissToast = useSetAtom(dismissToastAtom);
|
||||||
const firedRef = useRef<string | null>(null);
|
const firedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Installing from the toast used to fail invisibly: the toast was gone and
|
||||||
|
// the only trace was an error line in Settings. Say what is happening, and
|
||||||
|
// on failure leave a toast that retries and points at the manual installer.
|
||||||
|
const installFromToast = useCallback(async () => {
|
||||||
|
dismissToast(UPDATE_FAILED_TOAST);
|
||||||
|
setToast({
|
||||||
|
id: UPDATE_PROGRESS_TOAST,
|
||||||
|
iconSrc: Icons.Download,
|
||||||
|
displayName: 'Downloading update',
|
||||||
|
body: 'Lotus Chat will restart by itself when it’s done. You can keep chatting.',
|
||||||
|
roomName: 'System',
|
||||||
|
roomId: '',
|
||||||
|
onClick: () => undefined,
|
||||||
|
});
|
||||||
|
const failure = await install();
|
||||||
|
dismissToast(UPDATE_PROGRESS_TOAST);
|
||||||
|
if (!failure) return;
|
||||||
|
// Short on purpose; the full explanation and a Download installer button
|
||||||
|
// are in Settings → General → App Updates.
|
||||||
|
const busy = failure.phase !== 'install' && isNetworkUpdateError(failure.message);
|
||||||
|
setToast({
|
||||||
|
id: UPDATE_FAILED_TOAST,
|
||||||
|
iconSrc: Icons.Warning,
|
||||||
|
displayName: 'Update didn’t install',
|
||||||
|
body: `${busy ? 'The update server seems busy. ' : ''}Click to try again, or get the installer in Settings → General → App Updates.`,
|
||||||
|
roomName: 'System',
|
||||||
|
roomId: '',
|
||||||
|
sticky: true,
|
||||||
|
onClick: () => {
|
||||||
|
installFromToast();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [install, setToast, dismissToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri) return;
|
||||||
|
|
||||||
@@ -917,10 +958,12 @@ function TauriUpdateFeature() {
|
|||||||
body: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
|
body: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
|
||||||
roomName: 'System',
|
roomName: 'System',
|
||||||
roomId: '',
|
roomId: '',
|
||||||
onClick: install,
|
onClick: () => {
|
||||||
|
installFromToast();
|
||||||
|
},
|
||||||
sticky: true,
|
sticky: true,
|
||||||
});
|
});
|
||||||
}, [status, setToast, install]);
|
}, [status, setToast, installFromToast]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
describeUpdateError,
|
||||||
|
manualDownloadUrl,
|
||||||
|
MANUAL_DOWNLOAD_URL,
|
||||||
|
parseUpdateError,
|
||||||
|
} from './updateErrors';
|
||||||
|
|
||||||
|
test('parseUpdateError reads the native phase prefix', () => {
|
||||||
|
assert.deepEqual(parseUpdateError('download: error sending request for url (x)', 'check'), {
|
||||||
|
phase: 'download',
|
||||||
|
message: 'error sending request for url (x)',
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseUpdateError('install: access denied', 'download'), {
|
||||||
|
phase: 'install',
|
||||||
|
message: 'access denied',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUpdateError falls back to the caller phase for unprefixed errors', () => {
|
||||||
|
assert.deepEqual(parseUpdateError('error sending request', 'check'), {
|
||||||
|
phase: 'check',
|
||||||
|
message: 'error sending request',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the reported download failure reads as a busy server, not a failed check', () => {
|
||||||
|
const { phase, message } = parseUpdateError(
|
||||||
|
'download: error sending request for url (https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.nsis.zip)',
|
||||||
|
'check',
|
||||||
|
);
|
||||||
|
const text = describeUpdateError(phase, message);
|
||||||
|
assert.match(text, /didn’t finish downloading/);
|
||||||
|
assert.match(text, /download the installer yourself/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only signature failures are reported as a failed safety check', () => {
|
||||||
|
assert.match(
|
||||||
|
describeUpdateError('download', 'The signature X could not be decoded'),
|
||||||
|
/safety check/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(describeUpdateError('download', 'No space left on device'), /safety/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check failures never suggest the app is broken', () => {
|
||||||
|
assert.match(describeUpdateError('check', 'operation timed out'), /Lotus Chat still works/);
|
||||||
|
assert.match(describeUpdateError('check', 'weird'), /Lotus Chat still works/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual download link: Windows gets the installer, others the release page', () => {
|
||||||
|
assert.equal(
|
||||||
|
manualDownloadUrl('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Edg/140'),
|
||||||
|
MANUAL_DOWNLOAD_URL.windows,
|
||||||
|
);
|
||||||
|
assert.equal(manualDownloadUrl('Mozilla/5.0 (X11; Linux x86_64)'), MANUAL_DOWNLOAD_URL.other);
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/** Which step of the desktop update failed (the native side prefixes its errors). */
|
||||||
|
export type UpdatePhase = 'check' | 'download' | 'install';
|
||||||
|
|
||||||
|
/** Where to get the installer by hand when the in-app update keeps failing. */
|
||||||
|
export const MANUAL_DOWNLOAD_URL = {
|
||||||
|
windows:
|
||||||
|
'https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.exe',
|
||||||
|
other: 'https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/tag/latest',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const manualDownloadUrl = (userAgent: string): string =>
|
||||||
|
/Windows/i.test(userAgent) ? MANUAL_DOWNLOAD_URL.windows : MANUAL_DOWNLOAD_URL.other;
|
||||||
|
|
||||||
|
const NETWORK_PATTERN =
|
||||||
|
/error sending request|timed out|timeout|connection|connect|dns|network|reset|refused|unreachable|Could not fetch a valid release/i;
|
||||||
|
|
||||||
|
export const isNetworkUpdateError = (message: string): boolean => NETWORK_PATTERN.test(message);
|
||||||
|
|
||||||
|
/** Split `download: error sending request …` into its phase and the raw message. */
|
||||||
|
export const parseUpdateError = (
|
||||||
|
raw: string,
|
||||||
|
fallback: UpdatePhase,
|
||||||
|
): { phase: UpdatePhase; message: string } => {
|
||||||
|
const match = /^(check|download|install): ([\s\S]*)$/.exec(raw);
|
||||||
|
return match
|
||||||
|
? { phase: match[1] as UpdatePhase, message: match[2] }
|
||||||
|
: { phase: fallback, message: raw };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain-language sentence for an update failure. The raw error ("error
|
||||||
|
* sending request for url (…nsis.zip)") told users nothing about what to do.
|
||||||
|
*/
|
||||||
|
export const describeUpdateError = (phase: UpdatePhase, message: string): string => {
|
||||||
|
const network = isNetworkUpdateError(message);
|
||||||
|
if (phase === 'check') {
|
||||||
|
return network
|
||||||
|
? 'Couldn’t reach the update server. It may be busy, or your connection dropped. Lotus Chat still works; try again in a minute.'
|
||||||
|
: 'Couldn’t check for updates. Lotus Chat still works; try again later.';
|
||||||
|
}
|
||||||
|
if (phase === 'download') {
|
||||||
|
return network
|
||||||
|
? 'The update didn’t finish downloading after several tries. The update server may be busy. Try again in a minute, or download the installer yourself.'
|
||||||
|
: /signature|minisign|base64/i.test(message)
|
||||||
|
? 'The downloaded update didn’t pass its safety check, so it wasn’t installed. Try again, or download the installer yourself.'
|
||||||
|
: 'The update couldn’t be downloaded. Try again, or download the installer yourself.';
|
||||||
|
}
|
||||||
|
return 'The update downloaded but couldn’t be installed. Download the installer yourself and run it; your chats and settings are kept.';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user