Simplify and test updater service

- reverts to old UI
- moves more business logic into .ts file
- add tests for a few scenarios
This commit is contained in:
Mattias Granlund 2024-08-20 12:13:11 +01:00
parent 086d0cb0fb
commit 4a2e947c46
5 changed files with 521 additions and 183 deletions

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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<Status | undefined>();
private manifest = writable<UpdateManifest | undefined>(undefined, () => {
this.start();
return () => {
@ -35,48 +31,52 @@ export class UpdaterService {
};
});
update: Readable<Update | undefined> = 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;
private currentVersion = readable<string | undefined>(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
};
}
return { ...result, ...status, manual };
},
undefined
);
// Needed to reset dismissed modal when version changes.
currentVersion = writable<string | undefined>(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<string>('menu://global/update/clicked', () => {
this.unlistenMenu = this.tauri.listen<string>('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) {
this.loading.set(true);
try {
const update = await Promise.race([
checkUpdate(), // In DEV mode this never returns.
new Promise<UpdateResult>((resolve) =>
setTimeout(() => resolve({ shouldUpdate: false }), 30000)
this.tauri.checkUpdate(), // In DEV mode this never returns.
new Promise<UpdateResult>((_resolve, reject) =>
// For manual testing use resolve instead of reject here.
setTimeout(
() => reject(`Timed out after ${TIMEOUT_SECONDS} seconds.`),
TIMEOUT_SECONDS * 1000
)
)
]);
this.manual.set(manual);
if (!update.shouldUpdate && manual) {
this.status.set({ status: 'UPTODATE' });
} else if (update.manifest) {
this.manifest.set(update.manifest);
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: `

View File

@ -1,153 +1,341 @@
<script lang="ts">
import Link from '../shared/Link.svelte';
import newVersionSvg from '$lib/assets/empty-state/app-new-version.svg?raw';
import upToDateSvg from '$lib/assets/empty-state/app-up-to-date.svg?raw';
import { UpdaterService, type Update } from '$lib/backend/updater';
import { UpdaterService } from '$lib/backend/updater';
import { showToast } from '$lib/notifications/toasts';
import { getContext } from '$lib/utils/context';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import { fade } from 'svelte/transition';
const updaterService = getContext(UpdaterService);
const currentVersion = updaterService.currentVersion;
const update = updaterService.update;
const loading = updaterService.loading;
let status = $state<Update['status']>();
let version = $state<Update['version']>();
let lastVersion: string | undefined;
let dismissed = false;
let modal: Modal;
let version = $state<string | undefined>();
let releaseNotes = $state<string | undefined>();
let status = $state<string | undefined>();
$effect(() => {
if ($update) {
console.log($update);
handleUpdate($update);
}
({ version, releaseNotes, status } = $update || {});
});
function handleUpdate(update: Update) {
version = update?.version;
if (version !== lastVersion) {
dismissed = false;
}
status = update?.status;
const manual = update?.manual;
if (manual) {
modal.show();
} else if (status === 'ERROR') {
modal.close();
} else if (status && status !== 'UPTODATE' && !dismissed) {
modal.show();
} else if (version && !dismissed) {
modal.show();
}
lastVersion = version;
}
function handleDismiss() {
dismissed = true;
modal.close();
updaterService.dismiss();
}
</script>
<Modal width="xsmall" bind:this={modal}>
<div class="modal-illustration">
{#if status === 'UPTODATE' || status === 'DONE'}
{@html upToDateSvg}
{#if version || status === 'UPTODATE'}
<div class="update-banner" class:busy={$loading}>
<div class="floating-button">
<Button icon="cross-small" style="ghost" onclick={handleDismiss} />
</div>
<div class="img">
<div class="circle-img">
{#if status !== 'DONE'}
<svg
class="arrow-img"
width="12"
height="34"
viewBox="0 0 12 34"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 21V32.5M6 32.5L1 27.5M6 32.5L11 27.5"
stroke="var(--clr-scale-ntrl-100)"
stroke-width="1.5"
/>
<path
d="M6 0V11.5M6 11.5L1 6.5M6 11.5L11 6.5"
stroke="var(--clr-scale-ntrl-100)"
stroke-width="1.5"
/>
</svg>
{:else}
{@html newVersionSvg}
<svg
class="tick-img"
width="14"
height="11"
viewBox="0 0 14 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 4.07692L5.57143 9L13 1"
stroke="var(--clr-scale-ntrl-100)"
stroke-width="1.5"
/>
</svg>
{/if}
</div>
<h3 class="text-serif-32 modal-title">
<svg
width="60"
height="36"
viewBox="0 0 60 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M31.5605 35.5069C31.4488 35.5097 31.3368 35.5112 31.2245 35.5112H12.8571C5.75633 35.5112 0 29.7548 0 22.654C0 15.5532 5.75634 9.79688 12.8571 9.79688H16.123C18.7012 4.02354 24.493 0 31.2245 0C39.7331 0 46.7402 6.42839 47.6541 14.6934H49.5918C55.3401 14.6934 60 19.3533 60 25.1015C60 30.8498 55.3401 35.5097 49.5918 35.5097H32.4489C32.2692 35.5097 32.0906 35.5051 31.913 35.4961C31.7958 35.5009 31.6783 35.5045 31.5605 35.5069Z"
fill="var(--clr-scale-pop-70)"
/>
<g opacity="0.4">
<path
d="M39 35.5102V29.2505H29.25V9.75049H39V19.5005H48.75V29.2505H58.5V30.4877C56.676 33.4983 53.3688 35.5102 49.5918 35.5102H39Z"
fill="var(--clr-scale-pop-50)"
/>
<path
d="M46.3049 9.75049H39V1.93967C42.2175 3.65783 44.8002 6.4091 46.3049 9.75049Z"
fill="var(--clr-scale-pop-50)"
/>
<path
d="M9.75 35.1337C10.745 35.3806 11.7858 35.5117 12.8571 35.5117H29.25V29.2505H9.75V19.5005H19.5V9.75049H29.25V0.117188C25.4568 0.568673 22.0577 2.30464 19.5 4.87786V9.75049H16.144C16.137 9.7661 16.13 9.78173 16.123 9.79737H12.8571C11.7858 9.79737 10.745 9.92841 9.75 10.1753V19.5005H0.389701C0.135193 20.5097 0 21.5663 0 22.6545C0 25.0658 0.663785 27.322 1.81859 29.2505H9.75V35.1337Z"
fill="var(--clr-scale-pop-50)"
/>
</g>
</svg>
</div>
<h4 class="text-13 label">
{#if status === 'UPTODATE'}
You are up-to-date
{:else if status === 'DONE'}
Install complete!
{:else}
New {version} version
{/if}
</h3>
<p class="text-13 text-body modal-caption">
{#if status === 'UPTODATE'}
You're on GitButler {$currentVersion}, which is the most up-to-date version.
{:else}
Upgrade now for the latest features.
<br />
You can find release notes <Link href="https://discord.gg/WnTgfnmS">here</Link>.
{/if}
</p>
<div class="modal-actions">
{#if status !== 'UPTODATE' && status !== 'DONE'}
<Button
style="ghost"
outline
disabled={status === 'PENDING' || status === 'DOWNLOADED'}
onclick={handleDismiss}
>
Cancel
</Button>
{/if}
{#if status === 'UPTODATE'}
<Button style="pop" kind="solid" wide outline onclick={handleDismiss}>Got it!</Button>
{:else if status === 'DONE'}
<Button
style="pop"
kind="solid"
wide
outline
onclick={() => {
updaterService.relaunchApp();
}}
>
Restart
</Button>
{:else}
<Button
style="pop"
kind="solid"
wide
loading={status === 'PENDING' || status === 'DOWNLOADED'}
onclick={() => {
updaterService.installUpdate();
}}
>
{#if status === 'PENDING'}
{:else if status === 'PENDING'}
Downloading update...
{:else if status === 'DOWNLOADED'}
Installing update...
{:else}
Download {version}
{: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}
</h4>
<div class="buttons">
{#if releaseNotes}
<Button
style="ghost"
outline
onmousedown={() => {
showToast({
id: 'release-notes',
title: `Release notes for ${version}`,
message: releaseNotes || 'no release notes available'
});
}}
>
Release notes
</Button>
{/if}
<div class="status-section">
<div class="sliding-gradient"></div>
<div class="cta-btn" transition:fade={{ duration: 100 }}>
{#if !status}
<Button
wide
style="pop"
kind="solid"
onmousedown={async () => {
await updaterService.installUpdate();
}}
>
Download {version}
</Button>
{:else if status === 'UPTODATE'}
<Button
wide
style="pop"
kind="solid"
onmousedown={async () => {
updaterService.dismiss();
}}
>
Got it!
</Button>
{:else if status === 'DONE'}
<Button
style="pop"
kind="solid"
wide
onclick={async () => await updaterService.relaunchApp()}
>
Restart
</Button>
{/if}
</div>
</Modal>
</div>
</div>
</div>
{/if}
<style lang="postcss">
.modal-illustration {
.update-banner {
cursor: default;
user-select: none;
display: flex;
margin-bottom: 16px;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
max-width: 220px;
position: fixed;
z-index: var(--z-blocker);
bottom: 12px;
left: 12px;
padding: 24px;
background-color: var(--clr-bg-1);
border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m);
}
.modal-title {
word-wrap: break-word;
margin-bottom: 10px;
.label {
color: var(--clr-scale-ntrl-0);
}
.modal-caption {
color: var(--clr-text-2);
margin-bottom: 24px;
}
.modal-actions {
.buttons {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
/* STATUS SECTION */
.status-section {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
border-radius: var(--radius-m);
transition:
transform 0.15s ease-in-out,
height 0.15s ease-in-out;
}
.sliding-gradient {
z-index: 2;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 200%;
height: 100%;
mix-blend-mode: overlay;
background: linear-gradient(
80deg,
rgba(255, 255, 255, 0) 9%,
rgba(255, 255, 255, 0.5) 31%,
rgba(255, 255, 255, 0) 75%
);
animation: slide 3s ease-in-out infinite;
transition: width 0.2s ease-in-out;
}
@keyframes slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.cta-btn {
display: flex;
width: 100%;
position: relative;
}
.busy {
& .status-section {
height: 4px;
}
& .sliding-gradient {
width: 100%;
background: linear-gradient(
80deg,
rgba(255, 255, 255, 0) 9%,
rgba(255, 255, 255, 0.9) 31%,
rgba(255, 255, 255, 0) 75%
);
animation: slide 1.6s ease-in infinite;
}
& .arrow-img {
transform: rotate(180deg);
animation: moving-arrow 1s ease-in-out infinite;
}
}
/* IMAGE */
.img {
position: relative;
margin-bottom: 4px;
}
.circle-img {
position: absolute;
overflow: hidden;
bottom: -8px;
left: 17px;
width: 26px;
height: 26px;
border-radius: 50%;
background-color: var(--clr-scale-pop-40);
transition: transform 0.2s ease-in-out;
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: transparent;
box-shadow: inset 0 0 4px 4px var(--clr-scale-pop-40);
border-radius: 50%;
}
}
.arrow-img {
position: absolute;
top: -14px;
left: 7px;
/* transform: translateY(20px); */
}
.tick-img {
position: absolute;
top: 8px;
left: 6px;
}
.floating-button {
position: absolute;
right: 10px;
top: 10px;
}
@keyframes moving-arrow {
0% {
transform: translateY(0);
}
100% {
transform: translateY(21px);
}
}
</style>

View File

@ -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);