mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-09-11 12:25:42 +03:00
Add back custom app updater
- native one doesn't work on all platforms
This commit is contained in:
parent
889f8ac532
commit
0862d766bf
@ -27,7 +27,7 @@
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
"dialog": true,
|
||||
"dialog": false,
|
||||
"endpoints": [
|
||||
"https://app.gitbutler.com/releases/nightly/{{target}}-{{arch}}/{{current_version}}"
|
||||
],
|
||||
|
@ -27,7 +27,7 @@
|
||||
},
|
||||
"updater": {
|
||||
"active": true,
|
||||
"dialog": true,
|
||||
"dialog": false,
|
||||
"endpoints": [
|
||||
"https://app.gitbutler.com/releases/release/{{target}}-{{arch}}/{{current_version}}"
|
||||
],
|
||||
|
122
gitbutler-ui/src/lib/backend/updater.ts
Normal file
122
gitbutler-ui/src/lib/backend/updater.ts
Normal 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);
|
||||
}
|
@ -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>
|
||||
|
302
gitbutler-ui/src/lib/components/UpdateButton.svelte
Normal file
302
gitbutler-ui/src/lib/components/UpdateButton.svelte
Normal 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>
|
@ -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>
|
||||
|
@ -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} />
|
||||
|
@ -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$
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user