Add built-in auto-updater with tab-bar update pill
Wires electrobun's Updater to the /dl download host. The packaged app checks https://miniscp.com/dl on launch + every 6h; when a newer build exists, an accent-colored "update tool" appears next to Connections in the tab bar. It announces the update, becomes its own inline progress bar while downloading, then flips to "Restart to apply" (two-step: download, then user-triggered relaunch). - electrobun.config.ts: release.baseUrl = https://miniscp.com/dl (baked into version.json at build; miniscp.com fronted by an external proxy for TLS). - rpc.ts: checkForUpdate/downloadUpdate/applyUpdate requests + updateStatus push. - rpc-handlers.ts + index.ts: call Updater; poll on launch+6h; stream onStatusChange progress. checkForUpdate self-guards the dev channel. - rpc.service.ts + new updater.service.ts: signal-based state (available -> downloading -> ready, ready derived from downloadUpdate ok). - tab-bar.ts: single bg-primary pill; progress fills the pill itself. - icons.ts: register mynaDownload. i18n: tabBar + menu keys across all locales. - menu.ts + app.ts: "Check for Updates…" app-menu item -> UpdaterService.check(). - docs: build-and-release + architecture updated. Unsigned builds still update (applyUpdate strips the macOS quarantine xattr). Verified: tsc --noEmit (exit 0) + build:ui AOT (exit 0). Live pill + full download/apply flow require a two-build test on the host. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -226,6 +226,14 @@ would float over editor windows).
|
||||
`EDITOR_CONTEXT`, `IS_MAC_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
|
||||
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
|
||||
text fields).
|
||||
- `updater.service.ts` — auto-updater state for the tab-bar pill. Signals
|
||||
`state` (`idle→available→downloading→ready`|`error`), `version`, `progress`.
|
||||
Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on
|
||||
launch+6h, `onStatusChange` progress); `ready` is derived when the
|
||||
`downloadUpdate` request resolves ok (not a pushed phase). `download()` /
|
||||
`apply()` (relaunch) / `check()` (menu). Backend wiring lives in
|
||||
`src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the
|
||||
`/dl` host in `site/`). See build-and-release.md "Auto-updater".
|
||||
|
||||
## Theming
|
||||
|
||||
|
||||
@@ -88,9 +88,33 @@ internal box (plain HTTP `:80`, no domain/TLS). Deploys to `/srv/miniscp/`.
|
||||
- `site/deploy.sh` — run per build machine after `scripts/release.ts`; copies `artifacts/stable-*`
|
||||
and renames the build-numbered win portable zip to the stable `stable-win-x64-miniSCP-portable.zip`.
|
||||
`MINISCP_DL_DIR` overrides the target (stage locally, then rsync to the server).
|
||||
- **Future updater** (not wired yet): set `release.baseUrl = http://<host>/dl/` in
|
||||
`electrobun.config.ts` (currently unset) → builds start emitting `.patch` files that `deploy.sh`
|
||||
already ships. Then call the electrobun `Updater` API on launch/interval/tray.
|
||||
- `release.baseUrl = https://miniscp.com/dl` is set in `electrobun.config.ts`; `miniscp.com` is
|
||||
fronted by an external proxy for TLS (Caddy itself stays on `:80` behind it). The value is baked
|
||||
into `version.json` at build time, so only builds made after it was added check for updates.
|
||||
With `generatePatch` (default true), release builds now need **build-time network to
|
||||
`miniscp.com/dl`** so electrobun can fetch the currently-published version to diff a `.patch`
|
||||
(the first build after enabling it has nothing to diff and just ships the full artifacts).
|
||||
|
||||
## Auto-updater (app side)
|
||||
|
||||
Wired on top of the download host above. See the "Auto-updater" section in
|
||||
[architecture.md](architecture.md) for the runtime flow.
|
||||
|
||||
- **Backend** ([src/bun/index.ts](../src/bun/index.ts)): `Updater.checkForUpdate()` runs ~5s after launch and
|
||||
every 6h; on `updateAvailable` it pushes `rpc.send.updateStatus({phase:"available",…})`.
|
||||
`Updater.onStatusChange` streams `download-progress` (0–100) → `updateStatus{downloading}`.
|
||||
`checkForUpdate` self-guards the `dev` channel, so `bun run dev` never shows the pill.
|
||||
- **RPC** ([src/shared/rpc.ts](../src/shared/rpc.ts)): requests `checkForUpdate` / `downloadUpdate` /
|
||||
`applyUpdate` (handlers in [rpc-handlers.ts](../src/bun/rpc-handlers.ts) call the electrobun `Updater`);
|
||||
push message `updateStatus`.
|
||||
- **UI**: `UpdaterService` ([ui/src/app/updater.service.ts](../ui/src/app/updater.service.ts)) holds signals
|
||||
(`state`/`version`/`progress`); the tab-bar "update pill" ([tab-bar.ts](../ui/src/app/tab-bar/tab-bar.ts))
|
||||
is one accent (`bg-primary`) control that announces → fills as a progress bar while downloading →
|
||||
"Restart to apply". `state` is `available → downloading → ready` (ready derived from the
|
||||
`downloadUpdate` response). App-menu "Check for Updates…" ([menu.ts](../src/bun/menu.ts)) routes a
|
||||
`check-for-updates` menu action into `UpdaterService.check()`.
|
||||
- **Unsigned is fine:** `applyUpdate()` strips the macOS quarantine xattr before relaunch, so the
|
||||
swapped bundle isn't Gatekeeper-blocked.
|
||||
|
||||
## Icons
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@ export default {
|
||||
identifier: BRANDING.identifier,
|
||||
version: BRANDING.version,
|
||||
},
|
||||
// Auto-updater feed. The flat artifacts land under /dl on the host (see site/);
|
||||
// miniscp.com is fronted by an external proxy for TLS. baseUrl is baked into
|
||||
// version.json at build time, so only builds made after this takes effect check
|
||||
// for updates. generatePatch (default true) needs build-time network to this URL
|
||||
// to diff against the currently-published version.
|
||||
release: {
|
||||
baseUrl: "https://miniscp.com/dl",
|
||||
},
|
||||
build: {
|
||||
bun: {
|
||||
entrypoint: "src/bun/index.ts",
|
||||
|
||||
@@ -250,4 +250,26 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||
rpc.send.menuAction({ action });
|
||||
});
|
||||
|
||||
// ---- auto-updater: check on launch + every 6h, stream status to the tab bar ----
|
||||
// Updater.checkForUpdate() self-guards the dev channel (returns updateAvailable:
|
||||
// false), so the pill never appears during `bun run dev`.
|
||||
Updater.onStatusChange((entry) => {
|
||||
if (entry.status === "download-progress") {
|
||||
rpc.send.updateStatus({ phase: "downloading", progress: entry.details?.progress ?? 0 });
|
||||
} else if (entry.status === "error") {
|
||||
rpc.send.updateStatus({ phase: "error", error: entry.message });
|
||||
}
|
||||
});
|
||||
|
||||
async function runUpdateCheck(): Promise<void> {
|
||||
try {
|
||||
const r = await Updater.checkForUpdate();
|
||||
if (r.updateAvailable) rpc.send.updateStatus({ phase: "available", version: r.version });
|
||||
} catch {
|
||||
// offline / feed unreachable — stay quiet and retry on the next tick
|
||||
}
|
||||
}
|
||||
setTimeout(() => void runUpdateCheck(), 5_000); // let the window settle first
|
||||
setInterval(() => void runUpdateCheck(), 6 * 60 * 60 * 1000);
|
||||
|
||||
console.log(`${BRANDING.appName} started (${url})`);
|
||||
|
||||
@@ -36,6 +36,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
submenu: [
|
||||
{ label: t("menu.about", { app: BRANDING.appName }), action: "about" },
|
||||
{ type: "separator" },
|
||||
{ label: t("menu.checkForUpdates"), action: "check-for-updates" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "showAll" },
|
||||
|
||||
+20
-1
@@ -1,4 +1,4 @@
|
||||
import { BrowserView, Utils } from "electrobun/bun";
|
||||
import { BrowserView, Updater, Utils } from "electrobun/bun";
|
||||
import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||
import type { PanelContextMenu } from "./context-menu";
|
||||
@@ -322,6 +322,25 @@ export function createAppRPC({
|
||||
}),
|
||||
|
||||
listDrives: () => wrap(async () => ({ drives: listLocalDrives() })),
|
||||
|
||||
// ---- auto-updater ----
|
||||
checkForUpdate: () =>
|
||||
wrap(async () => {
|
||||
const r = await Updater.checkForUpdate();
|
||||
return { updateAvailable: r.updateAvailable, version: r.version };
|
||||
}),
|
||||
|
||||
downloadUpdate: () =>
|
||||
wrap(async () => {
|
||||
await Updater.downloadUpdate();
|
||||
return {};
|
||||
}),
|
||||
|
||||
applyUpdate: () =>
|
||||
wrap(async () => {
|
||||
await Updater.applyUpdate(); // relaunches + quits; may never resolve
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,10 @@ export const de = {
|
||||
newTab: "Neuer Tab ({mod}T)",
|
||||
closeTab: "Tab schließen ({mod}W)",
|
||||
options: "Optionen",
|
||||
update: "Update verfügbar",
|
||||
updating: "Aktualisiere",
|
||||
restartToUpdate: "Zum Anwenden neu starten",
|
||||
updateTooltip: "Update verfügbar: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Optionen",
|
||||
@@ -245,6 +249,7 @@ export const de = {
|
||||
},
|
||||
menu: {
|
||||
about: "Über {app}",
|
||||
checkForUpdates: "Nach Updates suchen…",
|
||||
quit: "Beenden",
|
||||
file: "Datei",
|
||||
newTab: "Neuer Tab",
|
||||
|
||||
@@ -28,6 +28,10 @@ export const en = {
|
||||
newTab: "New tab ({mod}T)",
|
||||
closeTab: "Close tab ({mod}W)",
|
||||
options: "Options",
|
||||
update: "Update available",
|
||||
updating: "Updating",
|
||||
restartToUpdate: "Restart to apply",
|
||||
updateTooltip: "Update available: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Options",
|
||||
@@ -249,6 +253,7 @@ export const en = {
|
||||
},
|
||||
menu: {
|
||||
about: "About {app}",
|
||||
checkForUpdates: "Check for Updates…",
|
||||
quit: "Quit",
|
||||
file: "File",
|
||||
newTab: "New Tab",
|
||||
|
||||
@@ -24,6 +24,10 @@ export const es = {
|
||||
newTab: "Nueva pestaña ({mod}T)",
|
||||
closeTab: "Cerrar pestaña ({mod}W)",
|
||||
options: "Opciones",
|
||||
update: "Actualización disponible",
|
||||
updating: "Actualizando",
|
||||
restartToUpdate: "Reiniciar para aplicar",
|
||||
updateTooltip: "Actualización disponible: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Opciones",
|
||||
@@ -245,6 +249,7 @@ export const es = {
|
||||
},
|
||||
menu: {
|
||||
about: "Acerca de {app}",
|
||||
checkForUpdates: "Buscar actualizaciones…",
|
||||
quit: "Salir",
|
||||
file: "Archivo",
|
||||
newTab: "Nueva pestaña",
|
||||
|
||||
@@ -26,6 +26,10 @@ export const eu = {
|
||||
newTab: "Fitxa berria ({mod}T)",
|
||||
closeTab: "Itxi fitxa ({mod}W)",
|
||||
options: "Aukerak",
|
||||
update: "Eguneratzea eskuragarri",
|
||||
updating: "Eguneratzen",
|
||||
restartToUpdate: "Berrabiarazi aplikatzeko",
|
||||
updateTooltip: "Eguneratzea eskuragarri: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Aukerak",
|
||||
@@ -247,6 +251,7 @@ export const eu = {
|
||||
},
|
||||
menu: {
|
||||
about: "{app} (r)i buruz",
|
||||
checkForUpdates: "Bilatu eguneratzeak…",
|
||||
quit: "Irten",
|
||||
file: "Fitxategia",
|
||||
newTab: "Fitxa berria",
|
||||
|
||||
@@ -24,6 +24,10 @@ export const fr = {
|
||||
newTab: "Nouvel onglet ({mod}T)",
|
||||
closeTab: "Fermer l'onglet ({mod}W)",
|
||||
options: "Options",
|
||||
update: "Mise à jour disponible",
|
||||
updating: "Mise à jour",
|
||||
restartToUpdate: "Redémarrer pour appliquer",
|
||||
updateTooltip: "Mise à jour disponible : miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "Options",
|
||||
@@ -245,6 +249,7 @@ export const fr = {
|
||||
},
|
||||
menu: {
|
||||
about: "À propos de {app}",
|
||||
checkForUpdates: "Rechercher des mises à jour…",
|
||||
quit: "Quitter",
|
||||
file: "Fichier",
|
||||
newTab: "Nouvel onglet",
|
||||
|
||||
@@ -25,6 +25,10 @@ export const ja = {
|
||||
newTab: "新しいタブ ({mod}T)",
|
||||
closeTab: "タブを閉じる ({mod}W)",
|
||||
options: "オプション",
|
||||
update: "アップデートあり",
|
||||
updating: "更新中",
|
||||
restartToUpdate: "再起動して適用",
|
||||
updateTooltip: "アップデートがあります: miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "オプション",
|
||||
@@ -245,6 +249,7 @@ export const ja = {
|
||||
},
|
||||
menu: {
|
||||
about: "{app} について",
|
||||
checkForUpdates: "アップデートを確認…",
|
||||
quit: "終了",
|
||||
file: "ファイル",
|
||||
newTab: "新しいタブ",
|
||||
|
||||
@@ -25,6 +25,10 @@ export const zh = {
|
||||
newTab: "新建标签页 ({mod}T)",
|
||||
closeTab: "关闭标签页 ({mod}W)",
|
||||
options: "选项",
|
||||
update: "有可用更新",
|
||||
updating: "正在更新",
|
||||
restartToUpdate: "重启以应用",
|
||||
updateTooltip: "有可用更新:miniSCP {version}",
|
||||
},
|
||||
options: {
|
||||
title: "选项",
|
||||
@@ -245,6 +249,7 @@ export const zh = {
|
||||
},
|
||||
menu: {
|
||||
about: "关于 {app}",
|
||||
checkForUpdates: "检查更新…",
|
||||
quit: "退出",
|
||||
file: "文件",
|
||||
newTab: "新建标签页",
|
||||
|
||||
@@ -157,6 +157,16 @@ export type AppRPC = {
|
||||
managerConnect: { params: { id: string }; response: Result };
|
||||
managerOpenStack: { params: { id: string }; response: Result };
|
||||
managerClose: { params: Record<string, never>; response: Result };
|
||||
// auto-updater (electrobun Updater)
|
||||
/** Main window: re-run the update check on demand (menu item). */
|
||||
checkForUpdate: {
|
||||
params: Record<string, never>;
|
||||
response: Result<{ updateAvailable: boolean; version: string }>;
|
||||
};
|
||||
/** Download the pending update (patch-chain, else full). Progress arrives via updateStatus. */
|
||||
downloadUpdate: { params: Record<string, never>; response: Result };
|
||||
/** Swap in the downloaded update and relaunch — quits the app, so may not resolve. */
|
||||
applyUpdate: { params: Record<string, never>; response: Result };
|
||||
};
|
||||
messages: {
|
||||
/** Editor → bun draft mirror; safety net for the non-cancellable native close. */
|
||||
@@ -179,6 +189,16 @@ export type AppRPC = {
|
||||
managerState: { open: boolean };
|
||||
/** bun → main: the manager window changed the connection store. */
|
||||
connectionsChanged: Record<string, never>;
|
||||
/**
|
||||
* bun → main: auto-updater lifecycle, drives the tab-bar update pill.
|
||||
* "ready" is not a phase — the UI derives it from downloadUpdate resolving ok.
|
||||
*/
|
||||
updateStatus: {
|
||||
phase: "available" | "downloading" | "error";
|
||||
version?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ToastService } from './toast.service';
|
||||
import { Toasts } from './toasts/toasts';
|
||||
import { TransferBar } from './transfer-bar/transfer-bar';
|
||||
import { TransfersService } from './transfers.service';
|
||||
import { UpdaterService } from './updater.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -52,6 +53,7 @@ export class App {
|
||||
readonly connections = inject(ConnectionsService);
|
||||
private readonly transfers = inject(TransfersService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
private readonly updater = inject(UpdaterService);
|
||||
|
||||
/** The connection-manager window is open — this window is modal-blocked. */
|
||||
readonly managerOpen = signal(false);
|
||||
@@ -144,6 +146,9 @@ export class App {
|
||||
case 'refresh-active':
|
||||
void this.panels.active().refresh();
|
||||
break;
|
||||
case 'check-for-updates':
|
||||
void this.updater.check();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
mynaCode,
|
||||
mynaCog,
|
||||
mynaDots,
|
||||
mynaDownload,
|
||||
mynaEdit,
|
||||
mynaFile,
|
||||
mynaFilePlus,
|
||||
@@ -51,6 +52,7 @@ export const APP_ICONS = {
|
||||
mynaCode,
|
||||
mynaCog,
|
||||
mynaDots,
|
||||
mynaDownload,
|
||||
mynaEdit,
|
||||
mynaFile,
|
||||
mynaFilePlus,
|
||||
|
||||
@@ -3,6 +3,13 @@ import { Electroview } from 'electrobun/view';
|
||||
import type { AppRPC } from '@shared/rpc';
|
||||
import type { AppSettings, TransferSnapshot } from '@shared/types';
|
||||
|
||||
export type UpdateStatus = {
|
||||
phase: 'available' | 'downloading' | 'error';
|
||||
version?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
|
||||
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
||||
|
||||
@@ -24,6 +31,7 @@ export class RpcService {
|
||||
private editorCloseListeners = new Set<() => void>();
|
||||
private managerStateListeners = new Set<(open: boolean) => void>();
|
||||
private connectionsListeners = new Set<() => void>();
|
||||
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
|
||||
|
||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||
private ready: Promise<void> = Promise.resolve();
|
||||
@@ -60,6 +68,9 @@ export class RpcService {
|
||||
connectionsChanged: () => {
|
||||
for (const listener of this.connectionsListeners) listener();
|
||||
},
|
||||
updateStatus: (status) => {
|
||||
for (const listener of this.updateStatusListeners) listener(status);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -108,6 +119,10 @@ export class RpcService {
|
||||
this.connectionsListeners.add(listener);
|
||||
}
|
||||
|
||||
onUpdateStatus(listener: (status: UpdateStatus) => void): void {
|
||||
this.updateStatusListeners.add(listener);
|
||||
}
|
||||
|
||||
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||
get request() {
|
||||
const rpc = this.rpc;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PanelsService } from '../panels.service';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { UpdaterService } from '../updater.service';
|
||||
|
||||
const THEMES = ['system', 'light', 'dark'] as const;
|
||||
const DARK_TONES = ['dark', 'darker'] as const;
|
||||
@@ -105,6 +106,37 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- auto-update tool: one accent pill that announces the update, becomes its
|
||||
own progress bar while downloading, then turns into "Restart to apply" -->
|
||||
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
|
||||
<button
|
||||
(click)="onUpdateClick()"
|
||||
[disabled]="updater.state() === 'downloading'"
|
||||
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
||||
class="relative mr-1 grid h-6 place-items-center overflow-hidden rounded-md px-2.5 text-[11px] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
||||
>
|
||||
@if (updater.state() === 'downloading') {
|
||||
<span
|
||||
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
|
||||
[style.width.%]="updater.progress()"
|
||||
></span>
|
||||
}
|
||||
<span class="relative flex items-center gap-1.5">
|
||||
@switch (updater.state()) {
|
||||
@case ('available') {
|
||||
<ng-icon name="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
|
||||
}
|
||||
@case ('downloading') {
|
||||
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
|
||||
}
|
||||
@case ('ready') {
|
||||
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- connection manager (own native window) -->
|
||||
<button
|
||||
(click)="openManager()"
|
||||
@@ -395,6 +427,7 @@ export class TabBar {
|
||||
readonly panels = inject(PanelsService);
|
||||
readonly settings = inject(SettingsService);
|
||||
readonly i18n = inject(I18nService);
|
||||
readonly updater = inject(UpdaterService);
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly localeOptions = SUPPORTED;
|
||||
@@ -445,6 +478,13 @@ export class TabBar {
|
||||
void this.rpc.request.openConnectionManager({});
|
||||
}
|
||||
|
||||
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
||||
onUpdateClick(): void {
|
||||
const state = this.updater.state();
|
||||
if (state === 'available') void this.updater.download();
|
||||
else if (state === 'ready') this.updater.apply();
|
||||
}
|
||||
|
||||
showAbout(): void {
|
||||
void this.rpc.request.showAbout({});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RpcService } from './rpc.service';
|
||||
|
||||
export type UpdaterState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
/**
|
||||
* Drives the tab-bar update pill. The backend pushes updateStatus events
|
||||
* (available / downloading progress / error); "ready" is derived locally when
|
||||
* downloadUpdate resolves ok. Two-step UX: available -> download -> ready ->
|
||||
* apply (relaunch).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UpdaterService {
|
||||
private rpc = inject(RpcService);
|
||||
|
||||
readonly state = signal<UpdaterState>('idle');
|
||||
readonly version = signal('');
|
||||
readonly progress = signal(0);
|
||||
|
||||
constructor() {
|
||||
if (!this.rpc.available) return;
|
||||
this.rpc.onUpdateStatus((s) => {
|
||||
if (s.phase === 'available') {
|
||||
this.version.set(s.version ?? '');
|
||||
this.state.set('available');
|
||||
} else if (s.phase === 'downloading') {
|
||||
this.state.set('downloading');
|
||||
this.progress.set(s.progress ?? 0);
|
||||
} else if (s.phase === 'error') {
|
||||
this.state.set('error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async download(): Promise<void> {
|
||||
if (this.state() !== 'available') return;
|
||||
this.state.set('downloading');
|
||||
this.progress.set(0);
|
||||
const r = await this.rpc.request.downloadUpdate({});
|
||||
this.state.set(r?.ok ? 'ready' : 'error');
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
if (this.state() !== 'ready') return;
|
||||
void this.rpc.request.applyUpdate({}); // quits + relaunches
|
||||
}
|
||||
|
||||
/** Manual re-check (menu item). */
|
||||
async check(): Promise<void> {
|
||||
if (!this.rpc.available) return;
|
||||
const r = await this.rpc.request.checkForUpdate({});
|
||||
if (r?.ok && r.updateAvailable) {
|
||||
this.version.set(r.version);
|
||||
this.state.set('available');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user