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:
2026-07-14 16:43:40 +02:00
co-authored by Claude
parent 43f1168d6a
commit a40433b99e
19 changed files with 261 additions and 4 deletions
+5
View File
@@ -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;
}
}
+2
View File
@@ -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,
+15
View File
@@ -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;
+40
View File
@@ -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({});
}
+57
View File
@@ -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');
}
}
}