Add back custom app updater

- native one doesn't work on all platforms
This commit is contained in:
Mattias Granlund 2024-02-22 15:03:30 +02:00
parent 889f8ac532
commit 0862d766bf
8 changed files with 437 additions and 4 deletions

View File

@ -27,7 +27,7 @@
},
"updater": {
"active": true,
"dialog": true,
"dialog": false,
"endpoints": [
"https://app.gitbutler.com/releases/nightly/{{target}}-{{arch}}/{{current_version}}"
],

View File

@ -27,7 +27,7 @@
},
"updater": {
"active": true,
"dialog": true,
"dialog": false,
"endpoints": [
"https://app.gitbutler.com/releases/release/{{target}}-{{arch}}/{{current_version}}"
],

View File

@ -0,0 +1,122 @@
import { showToast } from '$lib/notifications/toasts';
import {
checkUpdate,
installUpdate,
onUpdaterEvent,
type UpdateResult,
type UpdateStatus
} from '@tauri-apps/api/updater';
import posthog from 'posthog-js';
import {
BehaviorSubject,
switchMap,
Observable,
from,
map,
shareReplay,
interval,
timeout,
catchError,
of,
startWith,
combineLatestWith,
tap
} from 'rxjs';
// TOOD: Investigate why 'DOWNLOADED' is not in the type provided by Tauri.
export type Update =
| { version?: string; status?: UpdateStatus | 'DOWNLOADED'; body?: string }
| undefined;
export class UpdaterService {
private reload$ = new BehaviorSubject<void>(undefined);
private status$ = new BehaviorSubject<UpdateStatus | undefined>(undefined);
/**
* Example output:
* {version: "0.5.303", date: "2024-02-25 3:09:58.0 +00:00:00", body: "", status: "DOWNLOADED"}
*/
update$: Observable<Update>;
// We don't ever call this because the class is meant to be used as a singleton
unlistenFn: any;
constructor() {
onUpdaterEvent((status) => {
const err = status.error;
if (err) showErrorToast(err);
this.status$.next(status.status);
}).then((unlistenFn) => (this.unlistenFn = unlistenFn));
this.update$ = this.reload$.pipe(
// Now and then every hour indefinitely
switchMap(() => interval(60 * 60 * 1000).pipe(startWith(0))),
tap(() => this.status$.next(undefined)),
// Timeout needed to prevent hanging in dev mode
switchMap(() => from(checkUpdate()).pipe(timeout(10000))),
// The property `shouldUpdate` seems useless, only indicates presence of manifest
map((update: UpdateResult | undefined) => {
if (update?.shouldUpdate) return update.manifest;
}),
// Hide offline/timeout errors since no app ever notifies you about this
catchError((err) => {
if (!isOffline(err) && !isTimeoutError(err)) {
posthog.capture('Updater Check Error', err);
showErrorToast(err);
console.log(err);
}
return of(undefined);
}),
// Status is irrelevant without a proposed update so we merge the streams
combineLatestWith(this.status$),
map(([update, status]) => {
if (update) return { ...update, status };
return undefined;
}),
shareReplay(1)
);
// Use this for testing component manually (until we have actual tests)
// this.update$ = new BehaviorSubject({
// version: '0.5.303',
// date: '2024-02-25 3:09:58.0 +00:00:00',
// body: '- Improves the performance of virtual branch operations (quicker and lower CPU usage)\n- Large numbers of hunks for a file will only be rendered in the UI after confirmation'
// });
}
async install() {
try {
await installUpdate();
posthog.capture('App Update Successful');
} catch (e: any) {
// We expect toast to be shown by error handling in `onUpdaterEvent`
posthog.capture('App Update Failed', e);
}
}
}
function isOffline(err: any): boolean {
return typeof err == 'string' && err.includes('Could not fetch a valid release');
}
function isTimeoutError(err: any): boolean {
return err?.name == 'TimeoutError';
}
function showErrorToast(err: any) {
if (isOffline(err)) return;
showToast({
title: 'App update failed',
message: `
Something went wrong while updating the app.
You can download the latest release from our
[downloads](https://app.gitbutler.com/downloads) page.
\`\`\`
${err}
\`\`\`
`,
style: 'error'
});
posthog.capture('Updater Status Error', err);
}

View File

@ -125,4 +125,8 @@
:global(.info-message__text p:not(:last-child)) {
margin-bottom: var(--space-10);
}
:global(.info-message__text ul) {
list-style-type: circle;
padding: 0 0 0 var(--space-16);
}
</style>

View File

@ -0,0 +1,302 @@
<script lang="ts">
import Button from './Button.svelte';
import IconButton from './IconButton.svelte';
import { showToast } from '$lib/notifications/toasts';
import { relaunch } from '@tauri-apps/api/process';
import { installUpdate } from '@tauri-apps/api/updater';
import { fade } from 'svelte/transition';
import type { UpdaterService } from '$lib/backend/updater';
export let updaterService: UpdaterService;
$: update$ = updaterService.update$;
let dismissed = false;
</script>
{#if $update$?.version && $update$.status != 'UPTODATE' && !dismissed}
<div class="update-banner" class:busy={$update$?.status == 'PENDING'}>
<div class="floating-button">
<IconButton icon="cross-small" on:click={() => (dismissed = true)} />
</div>
<div class="img">
<div class="circle-img">
{#if $update$?.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-theme-scale-ntrl-100)"
stroke-width="1.5"
/>
<path
d="M6 0V11.5M6 11.5L1 6.5M6 11.5L11 6.5"
stroke="var(--clr-theme-scale-ntrl-100)"
stroke-width="1.5"
/>
</svg>
{:else}
<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-theme-scale-ntrl-100)"
stroke-width="1.5"
/>
</svg>
{/if}
</div>
<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-theme-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-theme-scale-pop-50)"
/>
<path
d="M46.3049 9.75049H39V1.93967C42.2175 3.65783 44.8002 6.4091 46.3049 9.75049Z"
fill="var(--clr-theme-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-theme-scale-pop-50)"
/>
</g>
</svg>
</div>
<h4 class="text-base-13 label">
{#if !$update$.status}
New version available
{:else if $update$.status == 'PENDING'}
Downloading update...
{:else if $update$.status == 'DONE'}
Installing update...
{:else if $update$.status == 'ERROR'}
Error occurred...
{/if}
</h4>
<!-- {$update$.body?.replace(/^ */gm, '').trim()} -->
<div class="buttons">
<Button
wide
kind="outlined"
on:click={() => {
const notes = $update$?.body || 'no release notes available';
console.log(notes);
showToast({
title: `Release notes for ${$update$?.version}`,
message: `
${notes}
`
});
}}>Release notes</Button
>
<div class="status-section">
<div class="sliding-gradient" />
{#if !$update$.status}
<div class="cta-btn" transition:fade={{ duration: 100 }}>
<Button wide on:click={() => installUpdate()}>Download {$update$.version}</Button>
</div>
{:else if $update$.status == 'DONE'}
<div class="cta-btn" transition:fade={{ duration: 100 }}>
<Button wide on:click={() => relaunch()}>Restart to update</Button>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style lang="postcss">
.update-banner {
cursor: default;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-16);
width: 100%;
max-width: 220px;
position: fixed;
bottom: var(--space-12);
left: var(--space-12);
padding: var(--space-24);
background-color: var(--clr-theme-container-light);
border: 1px solid var(--clr-theme-container-outline-light);
border-radius: var(--radius-m);
}
.label {
color: var(--clr-theme-scale-ntrl-0);
}
.buttons {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-8);
}
/* STATUS SECTION */
.status-section {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
height: var(--size-btn-m);
width: 100%;
background-color: var(--clr-theme-pop-element);
border-radius: var(--radius-m);
transition:
transform 0.15s ease-in-out,
height 0.15s ease-in-out;
}
.sliding-gradient {
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: var(--space-4);
}
& .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-theme-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-theme-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: var(--space-10);
top: var(--space-10);
}
@keyframes moving-arrow {
0% {
transform: translateY(0);
}
100% {
transform: translateY(21px);
}
}
</style>

View File

@ -36,7 +36,7 @@
bottom: var(--space-20);
right: var(--space-20);
gap: var(--space-8);
max-width: 22rem;
max-width: 30rem;
z-index: 50;
}
</style>

View File

@ -2,6 +2,7 @@
import '../styles/main.postcss';
import ShareIssueModal from '$lib/components/ShareIssueModal.svelte';
import UpdateButton from '$lib/components/UpdateButton.svelte';
import ToastController from '$lib/notifications/ToastController.svelte';
import { SETTINGS_CONTEXT, loadUserSettings } from '$lib/settings/userSettings';
import * as events from '$lib/utils/events';
@ -14,7 +15,7 @@
import { goto } from '$app/navigation';
export let data: LayoutData;
const { cloud, user$ } = data;
const { cloud, user$, updaterService } = data;
const userSettings = loadUserSettings();
initTheme(userSettings);
@ -51,3 +52,4 @@
<Toaster />
<ShareIssueModal bind:this={shareIssueModal} user={$user$} {cloud} />
<ToastController />
<UpdateButton {updaterService} />

View File

@ -2,6 +2,7 @@ import { initPostHog } from '$lib/analytics/posthog';
import { initSentry } from '$lib/analytics/sentry';
import { getCloudApiClient } from '$lib/backend/cloud';
import { ProjectService } from '$lib/backend/projects';
import { UpdaterService } from '$lib/backend/updater';
import { appMetricsEnabled, appErrorReportingEnabled } from '$lib/config/appSettings';
import { UserService } from '$lib/stores/user';
import lscache from 'lscache';
@ -32,6 +33,7 @@ export const load: LayoutLoad = async ({ fetch: realFetch }: { fetch: typeof fet
if (enabled) initPostHog();
});
const userService = new UserService();
const updaterService = new UpdaterService();
// TODO: Find a workaround to avoid this dynamic import
// https://github.com/sveltejs/kit/issues/905
@ -41,6 +43,7 @@ export const load: LayoutLoad = async ({ fetch: realFetch }: { fetch: typeof fet
return {
projectService: new ProjectService(defaultPath),
cloud: getCloudApiClient({ fetch: realFetch }),
updaterService,
userService,
user$: userService.user$
};