diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts new file mode 100644 index 000000000..1847dab75 --- /dev/null +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -0,0 +1,11 @@ +import { invoke as invokeIpc, listen as listenIpc } from './ipc'; +import { getVersion } from '@tauri-apps/api/app'; +import { checkUpdate, onUpdaterEvent } from '@tauri-apps/api/updater'; + +export class Tauri { + invoke = invokeIpc; + listen = listenIpc; + checkUpdate = checkUpdate; + onUpdaterEvent = onUpdaterEvent; + currentVersion = getVersion; +} diff --git a/apps/desktop/src/lib/backend/updater.test.ts b/apps/desktop/src/lib/backend/updater.test.ts new file mode 100644 index 000000000..7391cf48e --- /dev/null +++ b/apps/desktop/src/lib/backend/updater.test.ts @@ -0,0 +1,101 @@ +import { Tauri } from './tauri'; +import { UpdaterService } from './updater'; +import { get } from 'svelte/store'; +import { expect, test, describe, vi, beforeEach, afterEach } from 'vitest'; + +/** + * It is important to understand the sync `get` method performs a store subscription + * under the hood. + */ +describe('Updater', () => { + let tauri: Tauri; + let updater: UpdaterService; + + beforeEach(() => { + vi.useFakeTimers(); + tauri = new Tauri(); + updater = new UpdaterService(tauri); + vi.spyOn(tauri, 'listen').mockReturnValue(async () => {}); + vi.spyOn(tauri, 'onUpdaterEvent').mockReturnValue(Promise.resolve(() => {})); + vi.spyOn(tauri, 'currentVersion').mockReturnValue(Promise.resolve('0.1')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('should not show up-to-date on interval check', async () => { + vi.spyOn(tauri, 'checkUpdate').mockReturnValue( + Promise.resolve({ + shouldUpdate: false + }) + ); + + await updater.checkForUpdate(); + expect(get(updater.update)).toHaveProperty('status', undefined); + }); + + test('should show up-to-date on manual check', async () => { + vi.spyOn(tauri, 'checkUpdate').mockReturnValue( + Promise.resolve({ + shouldUpdate: false + }) + ); + await updater.checkForUpdate(true); // manual = true; + expect(get(updater.update)).toHaveProperty('status', 'UPTODATE'); + }); + + test('should prompt again on new version', async () => { + const body = 'release notes'; + const date = '2024-01-01'; + + vi.spyOn(tauri, 'checkUpdate').mockReturnValue( + Promise.resolve({ + shouldUpdate: true, + manifest: { version: '1', body, date } + }) + ); + + await updater.checkForUpdate(); + const update1 = get(updater.update); + expect(update1).toHaveProperty('version', '1'); + expect(update1).toHaveProperty('releaseNotes', body); + updater.dismiss(); + + vi.spyOn(tauri, 'checkUpdate').mockReturnValue( + Promise.resolve({ + shouldUpdate: true, + manifest: { version: '2', body, date } + }) + ); + await updater.checkForUpdate(); + const update2 = get(updater.update); + expect(update2).toHaveProperty('version', '2'); + expect(update2).toHaveProperty('releaseNotes', body); + }); + + test('should not prompt download for seen version', async () => { + const version = '1'; + const body = 'release notes'; + const date = '2024-01-01'; + + vi.spyOn(tauri, 'checkUpdate').mockReturnValue( + Promise.resolve({ + shouldUpdate: true, + manifest: { version, body, date } + }) + ); + const updater = new UpdaterService(tauri); + await updater.checkForUpdate(); + + const update1 = get(updater.update); + expect(update1).toHaveProperty('version', version); + expect(update1).toHaveProperty('releaseNotes', body); + + updater.dismiss(); + await updater.checkForUpdate(); + const update2 = get(updater.update); + expect(update2).toHaveProperty('version', undefined); + expect(update2).toHaveProperty('releaseNotes', undefined); + }); +}); diff --git a/apps/desktop/src/lib/backend/updater.ts b/apps/desktop/src/lib/backend/updater.ts index f45d83521..6787cbceb 100644 --- a/apps/desktop/src/lib/backend/updater.ts +++ b/apps/desktop/src/lib/backend/updater.ts @@ -1,33 +1,29 @@ -import { listen } from './ipc'; +import { Tauri } from './tauri'; import { showToast } from '$lib/notifications/toasts'; -import { getVersion } from '@tauri-apps/api/app'; import { relaunch } from '@tauri-apps/api/process'; import { - checkUpdate, installUpdate, - onUpdaterEvent, type UpdateResult, type UpdateManifest, type UpdateStatus } from '@tauri-apps/api/updater'; import posthog from 'posthog-js'; -import { derived, writable, type Readable } from 'svelte/store'; +import { derived, readable, writable } from 'svelte/store'; -// TODO: Investigate why 'DOWNLOADED' is not in the type provided by Tauri. -export type Update = { - version?: string; - status?: UpdateStatus | 'DOWNLOADED'; - body?: string; - manual: boolean; -}; +type Status = UpdateStatus | 'DOWNLOADED'; +const TIMEOUT_SECONDS = 30; +/** + * Note that the Tauri API `checkUpdate` hangs indefinitely in dev mode, build + * a nightly if you want to test the updater manually. + * + * export TAURI_PRIVATE_KEY=doesnot + * export TAURI_KEY_PASSWORD=matter + * ./scripts/release.sh --channel nightly --version "0.5.678" + */ export class UpdaterService { - // True if manually initiated check. - private manual = writable(false); - - // An object rather than string to prevent unique deduplication. - private status = writable<{ status: UpdateStatus } | undefined>(undefined); - + readonly loading = writable(false); + readonly status = writable(); private manifest = writable(undefined, () => { this.start(); return () => { @@ -35,48 +31,52 @@ export class UpdaterService { }; }); - update: Readable = derived( - [this.status, this.manifest, this.manual], - ([status, result, manual]) => { - // Do not emit when up-to-date unless manually initiated. - if (status?.status === 'UPTODATE' && result && !manual) { - return; - } - return { ...result, ...status, manual }; - }, - undefined + private currentVersion = readable(undefined, (set) => { + this.tauri.currentVersion().then((version) => set(version)); + }); + + readonly update = derived( + [this.manifest, this.status, this.currentVersion], + ([manifest, status, currentVersion]) => { + return { + version: manifest?.version, + releaseNotes: manifest?.body, + status, + currentVersion + }; + } ); - // Needed to reset dismissed modal when version changes. - currentVersion = writable(undefined); - readonly version = derived(this.update, (update) => update?.version); + private intervalId: any; + private seenVersion: string | undefined; - intervalId: any; - unlistenStatusFn: any; - unlistenManualCheckFn: any; + unlistenStatus?: () => void; + unlistenMenu?: () => void; - constructor() {} + constructor(private tauri: Tauri) {} private async start() { - this.currentVersion.set(await getVersion()); - this.unlistenManualCheckFn = listen('menu://global/update/clicked', () => { + this.unlistenMenu = this.tauri.listen('menu://global/update/clicked', () => { this.checkForUpdate(true); }); - this.unlistenStatusFn = await onUpdaterEvent((event) => { + + this.unlistenStatus = await this.tauri.onUpdaterEvent((event) => { const { error, status } = event; + this.status.set(status); if (error) { - showErrorToast(error); + handleError(error, false); posthog.capture('App Update Status Error', { error }); } - this.status.set({ status }); }); + setInterval(async () => await this.checkForUpdate(), 3600000); // hourly this.checkForUpdate(); } private async stop() { - this.unlistenStatusFn?.(); - this.unlistenManualCheckFn?.(); + this.unlistenStatus?.(); + this.unlistenMenu?.(); + if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = undefined; @@ -84,41 +84,78 @@ export class UpdaterService { } async checkForUpdate(manual = false) { - const update = await Promise.race([ - checkUpdate(), // In DEV mode this never returns. - new Promise((resolve) => - setTimeout(() => resolve({ shouldUpdate: false }), 30000) - ) - ]); - this.manual.set(manual); - if (!update.shouldUpdate && manual) { - this.status.set({ status: 'UPTODATE' }); - } else if (update.manifest) { - this.manifest.set(update.manifest); + this.loading.set(true); + try { + const update = await Promise.race([ + this.tauri.checkUpdate(), // In DEV mode this never returns. + new Promise((_resolve, reject) => + // For manual testing use resolve instead of reject here. + setTimeout( + () => reject(`Timed out after ${TIMEOUT_SECONDS} seconds.`), + TIMEOUT_SECONDS * 1000 + ) + ) + ]); + await this.processUpdate(update, manual); + } catch (err: unknown) { + // No toast unless manually invoked. + if (manual) { + handleError(err, true); + } else { + console.error(err); + } + } finally { + this.loading.set(false); + } + } + + private async processUpdate(update: UpdateResult, manual: boolean) { + const { shouldUpdate, manifest } = update; + if (shouldUpdate === false && manual) { + this.status.set('UPTODATE'); + } + if (manifest && manifest.version !== this.seenVersion) { + this.manifest.set(manifest); + this.seenVersion = manifest.version; } } async installUpdate() { + this.loading.set(true); try { await installUpdate(); posthog.capture('App Update Successful'); } catch (err: any) { // We expect toast to be shown by error handling in `onUpdaterEvent` posthog.capture('App Update Install Error', { error: err }); + } finally { + this.loading.set(false); } } - relaunchApp() { - relaunch(); + async relaunchApp() { + try { + await relaunch(); + } catch (err: unknown) { + handleError(err, true); + } + } + + dismiss() { + this.manifest.set(undefined); + this.status.set(undefined); } } function isOffline(err: any): boolean { - return typeof err === 'string' && err.includes('Could not fetch a valid release'); + return ( + typeof err === 'string' && + (err.includes('Could not fetch a valid release') || err.includes('Network Error')) + ); } -function showErrorToast(err: any) { - if (isOffline(err)) return; +function handleError(err: any, manual: boolean) { + if (!manual && isOffline(err)) return; showToast({ title: 'App update failed', message: ` diff --git a/apps/desktop/src/lib/components/AppUpdater.svelte b/apps/desktop/src/lib/components/AppUpdater.svelte index 58f54a511..9c905755f 100644 --- a/apps/desktop/src/lib/components/AppUpdater.svelte +++ b/apps/desktop/src/lib/components/AppUpdater.svelte @@ -1,153 +1,341 @@ - - - - - - - - +
+
+ {#if status !== 'DONE'} + + + + {:else} - Download {version} + + + {/if} - - {/if} +
+ + + + + + + + + +
+ +

+ {#if status === 'UPTODATE'} + You are up-to-date + {:else if status === 'PENDING'} + Downloading update... + {:else if status === 'DOWNLOADED'} + Installing update... + {:else if status === 'DONE'} + Install complete + {:else if status === 'CHECKING'} + Checking for update + {:else if status === 'ERROR'} + Error occurred... + {:else if version} + New version available + {/if} +

+ +
+ {#if releaseNotes} + + {/if} +
+
+
+ {#if !status} + + {:else if status === 'UPTODATE'} + + {:else if status === 'DONE'} + + {/if} +
+
+
-
+{/if} diff --git a/apps/desktop/src/routes/+layout.ts b/apps/desktop/src/routes/+layout.ts index 05b95cf99..521198db8 100644 --- a/apps/desktop/src/routes/+layout.ts +++ b/apps/desktop/src/routes/+layout.ts @@ -6,6 +6,7 @@ import { GitConfigService } from '$lib/backend/gitConfigService'; import { HttpClient } from '$lib/backend/httpClient'; import { ProjectService } from '$lib/backend/projects'; import { PromptService } from '$lib/backend/prompt'; +import { Tauri } from '$lib/backend/tauri'; import { UpdaterService } from '$lib/backend/updater'; import { RemotesService } from '$lib/remotes/service'; import { RustSecretService } from '$lib/secrets/secretsService'; @@ -32,7 +33,7 @@ export const load: LayoutLoad = async () => { const httpClient = new HttpClient(); const authService = new AuthService(); const projectService = new ProjectService(defaultPath, httpClient); - const updaterService = new UpdaterService(); + const updaterService = new UpdaterService(new Tauri()); const promptService = new PromptService(); const userService = new UserService(httpClient);