From a40433b99ec21d31270c6abe82c3ffae799a8df7 Mon Sep 17 00:00:00 2001 From: Jokin Date: Tue, 14 Jul 2026 16:43:40 +0200 Subject: [PATCH] Add built-in auto-updater with tab-bar update pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/architecture.md | 8 +++++ docs/build-and-release.md | 30 ++++++++++++++++-- electrobun.config.ts | 8 +++++ src/bun/index.ts | 22 ++++++++++++++ src/bun/menu.ts | 2 ++ src/bun/rpc-handlers.ts | 21 ++++++++++++- src/shared/i18n/de.ts | 5 +++ src/shared/i18n/en.ts | 5 +++ src/shared/i18n/es.ts | 5 +++ src/shared/i18n/eu.ts | 5 +++ src/shared/i18n/fr.ts | 5 +++ src/shared/i18n/ja.ts | 5 +++ src/shared/i18n/zh.ts | 5 +++ src/shared/rpc.ts | 20 ++++++++++++ ui/src/app/app.ts | 5 +++ ui/src/app/icons.ts | 2 ++ ui/src/app/rpc.service.ts | 15 +++++++++ ui/src/app/tab-bar/tab-bar.ts | 40 ++++++++++++++++++++++++ ui/src/app/updater.service.ts | 57 +++++++++++++++++++++++++++++++++++ 19 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 ui/src/app/updater.service.ts diff --git a/docs/architecture.md b/docs/architecture.md index 79992f1..b3732f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/build-and-release.md b/docs/build-and-release.md index ecb6df4..d5f89b6 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -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:///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 diff --git a/electrobun.config.ts b/electrobun.config.ts index 40798eb..72fa78b 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -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", diff --git a/src/bun/index.ts b/src/bun/index.ts index 57bf3d4..907cc51 100644 --- a/src/bun/index.ts +++ b/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 { + 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})`); diff --git a/src/bun/menu.ts b/src/bun/menu.ts index 274cb50..02ec9c7 100644 --- a/src/bun/menu.ts +++ b/src/bun/menu.ts @@ -36,6 +36,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise 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 {}; + }), }, }, }); diff --git a/src/shared/i18n/de.ts b/src/shared/i18n/de.ts index 81d3dc4..9a90d86 100644 --- a/src/shared/i18n/de.ts +++ b/src/shared/i18n/de.ts @@ -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", diff --git a/src/shared/i18n/en.ts b/src/shared/i18n/en.ts index f9fc33d..3c167c8 100644 --- a/src/shared/i18n/en.ts +++ b/src/shared/i18n/en.ts @@ -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", diff --git a/src/shared/i18n/es.ts b/src/shared/i18n/es.ts index 3165719..82863e3 100644 --- a/src/shared/i18n/es.ts +++ b/src/shared/i18n/es.ts @@ -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", diff --git a/src/shared/i18n/eu.ts b/src/shared/i18n/eu.ts index 73afa8d..2b2996a 100644 --- a/src/shared/i18n/eu.ts +++ b/src/shared/i18n/eu.ts @@ -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", diff --git a/src/shared/i18n/fr.ts b/src/shared/i18n/fr.ts index bb2f00e..d619bac 100644 --- a/src/shared/i18n/fr.ts +++ b/src/shared/i18n/fr.ts @@ -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", diff --git a/src/shared/i18n/ja.ts b/src/shared/i18n/ja.ts index 0af58af..369a6a4 100644 --- a/src/shared/i18n/ja.ts +++ b/src/shared/i18n/ja.ts @@ -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: "新しいタブ", diff --git a/src/shared/i18n/zh.ts b/src/shared/i18n/zh.ts index bdec8e1..9f1b097 100644 --- a/src/shared/i18n/zh.ts +++ b/src/shared/i18n/zh.ts @@ -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: "新建标签页", diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 215f4c2..6aa9f8c 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -157,6 +157,16 @@ export type AppRPC = { managerConnect: { params: { id: string }; response: Result }; managerOpenStack: { params: { id: string }; response: Result }; managerClose: { params: Record; response: Result }; + // auto-updater (electrobun Updater) + /** Main window: re-run the update check on demand (menu item). */ + checkForUpdate: { + params: Record; + response: Result<{ updateAvailable: boolean; version: string }>; + }; + /** Download the pending update (patch-chain, else full). Progress arrives via updateStatus. */ + downloadUpdate: { params: Record; response: Result }; + /** Swap in the downloaded update and relaunch — quits the app, so may not resolve. */ + applyUpdate: { params: Record; 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; + /** + * 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; + }; }; }; }; diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 6fa6648..da85662 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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; } } diff --git a/ui/src/app/icons.ts b/ui/src/app/icons.ts index 3f0240c..34ab04d 100644 --- a/ui/src/app/icons.ts +++ b/ui/src/app/icons.ts @@ -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, diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index 6ea7f89..b35bf10 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -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> | undefined; private ready: Promise = 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; diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index e43a6bb..071246b 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -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;
+ + @if (updater.state() !== 'idle' && updater.state() !== 'error') { + + } +