diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index dcde048..2854310 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -92,6 +92,9 @@ jobs: run: npm install -g @tauri-apps/cli@^2 - name: Bundle (.app + .dmg) working-directory: src-tauri + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: tauri build --target aarch64-apple-darwin - name: Upload macOS bundle uses: actions/upload-artifact@v4 diff --git a/console/index.html b/console/index.html index 293415a..1ae331d 100644 --- a/console/index.html +++ b/console/index.html @@ -14,6 +14,7 @@
+
diff --git a/console/src/main.ts b/console/src/main.ts index c18fae0..013d3c9 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -89,11 +89,17 @@ async function tick(): Promise { } } +// The Tauri command bridge — present only inside the desktop shell (the browser +// build has no `__TAURI__`, so callers no-op / hide their UI). +type Invoke = (cmd: string, args?: Record) => Promise; +function tauriInvoke(): Invoke | undefined { + return (globalThis as { __TAURI__?: { core?: { invoke?: Invoke } } }).__TAURI__?.core + ?.invoke; +} + // Ask the backend to start the core — only meaningful inside the Tauri shell. async function startCore(): Promise { - const invoke = ( - globalThis as { __TAURI__?: { core?: { invoke?: (c: string) => Promise } } } - ).__TAURI__?.core?.invoke; + const invoke = tauriInvoke(); if (!invoke) return; // browser build — MockSource, no core try { await invoke("start_core"); @@ -102,6 +108,67 @@ async function startCore(): Promise { } } +// Remote upgrade: the topbar "檢查更新" button. First click checks the nightly +// release; if a newer signed build exists, the button turns into an install +// action that downloads, verifies, and restarts into it. Desktop-only — hidden +// in the browser build (no command bridge). +interface UpdateInfo { + version: string; + current: string; + notes: string | null; +} +function setupUpdater(): void { + const el = document.getElementById("update-btn") as HTMLButtonElement | null; + const invoke = tauriInvoke(); + if (!el || !invoke) return; // browser build — no updater + el.hidden = false; + let pending: UpdateInfo | null = null; + + const reset = (): void => { + pending = null; + el.textContent = "檢查更新"; + el.classList.remove("has-update"); + }; + + async function check(btn: HTMLButtonElement, inv: Invoke): Promise { + btn.disabled = true; + btn.textContent = "檢查中…"; + try { + const info = await inv("check_update"); + if (info) { + pending = info; + btn.textContent = `更新到 v${info.version} ↻`; + btn.classList.add("has-update"); + note("info", `發現新版 v${info.version}(目前 v${info.current})— 按按鈕安裝並重啟`); + } else { + note("info", "已是最新版"); + btn.textContent = "已是最新版"; + window.setTimeout(reset, 4000); + } + } catch (e) { + reset(); + note("error", `檢查更新失敗:${errText(e)}`); + } finally { + btn.disabled = false; + } + } + + async function install(btn: HTMLButtonElement, inv: Invoke): Promise { + btn.disabled = true; + btn.textContent = "安裝中…"; + try { + // On success the backend restarts the app, so this may never resolve. + await inv("install_update"); + } catch (e) { + btn.disabled = false; + btn.textContent = pending ? `更新到 v${pending.version} ↻` : "檢查更新"; + note("error", `安裝更新失敗:${errText(e)}`); + } + } + + el.addEventListener("click", () => void (pending ? install(el, invoke) : check(el, invoke))); +} + // Boot order matters: subscribe to the log streams FIRST, then start the core, // so the spawn → handshake → ready lifecycle lines are captured, not lost. async function boot(): Promise { @@ -109,6 +176,7 @@ async function boot(): Promise { if (activity && mcp) await bindBackend(activity, mcp); if (clusterLabel) clusterLabel.textContent = CLUSTER; note("info", `polling cluster "${CLUSTER}" every ${POLL_MS / 1000}s`); + setupUpdater(); await startCore(); void tick(); window.setInterval(() => void tick(), POLL_MS); diff --git a/console/src/styles.css b/console/src/styles.css index 68f506b..4fc2c49 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -85,6 +85,31 @@ body { .status.err { color: var(--s-unhealthy); } +.update-btn { + appearance: none; + border: 1px solid var(--border); + background: transparent; + color: var(--muted); + font: inherit; + font-size: 12px; + padding: 3px 10px; + border-radius: 6px; + cursor: pointer; +} +.update-btn:hover:not(:disabled) { + color: var(--text); + border-color: var(--s-starting); +} +.update-btn:disabled { + opacity: 0.6; + cursor: default; +} +/* A build is waiting — draw the eye to it. */ +.update-btn.has-update { + color: #fff; + background: var(--s-starting); + border-color: var(--s-starting); +} .content { display: flex; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b713692..1ae1d7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,11 @@ tauri-plugin-log = "2" tauri-plugin-shell = "2" tokio = { version = "1", features = ["sync", "rt", "macros"] } +# Remote upgrade (ADR: remote-upgrade): the installed app pulls a signed nightly +# bundle from the GitHub release and replaces itself in place. Driven from Rust +# (custom commands below), so the web skin stays free of the updater JS package. +tauri-plugin-updater = "2" + # Its own workspace so the desktop build stays out of the root workspace's # `cargo build --workspace` (kept to the small crates + CI). [workspace] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65ad71c..f191380 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod mcp; use mcp::McpClient; use serde_json::{json, Value}; use tauri::{Emitter, Manager}; +use tauri_plugin_updater::UpdaterExt; use tokio::sync::Mutex as AsyncMutex; /// Holds the core client once the frontend has asked us to start it. Kept behind @@ -95,10 +96,59 @@ async fn deploy_list( } } +/// What the frontend needs to render the "update available" state: the version +/// on the release vs. what's running, plus the release notes. +#[derive(serde::Serialize)] +struct UpdateInfo { + version: String, + current: String, + notes: Option, +} + +/// Ask the release endpoint whether a newer signed build exists. Returns `None` +/// when we're already current. Drives the topbar "檢查更新" button. +#[tauri::command] +async fn check_update(app: tauri::AppHandle) -> Result, String> { + let updater = app.updater().map_err(|e| e.to_string())?; + match updater.check().await.map_err(|e| e.to_string())? { + Some(update) => Ok(Some(UpdateInfo { + version: update.version.clone(), + current: update.current_version.clone(), + notes: update.body.clone(), + })), + None => Ok(None), + } +} + +/// Download + verify + install the pending update, then restart into it. The +/// bundle's minisign signature is checked against the embedded pubkey before it +/// is applied, so a tampered release can't be installed. +#[tauri::command] +async fn install_update(app: tauri::AppHandle) -> Result<(), String> { + let updater = app.updater().map_err(|e| e.to_string())?; + let Some(update) = updater.check().await.map_err(|e| e.to_string())? else { + return Err("no update available".to_string()); + }; + let _ = app.emit( + "app-log", + json!({ "level": "info", "msg": format!("downloading update v{}…", update.version) }), + ); + update + .download_and_install(|_chunk, _total| {}, || {}) + .await + .map_err(|e| e.to_string())?; + let _ = app.emit( + "app-log", + json!({ "level": "info", "msg": "update installed — restarting…" }), + ); + app.restart(); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -110,7 +160,12 @@ pub fn run() { app.manage(Core::default()); Ok(()) }) - .invoke_handler(tauri::generate_handler![start_core, deploy_list]) + .invoke_handler(tauri::generate_handler![ + start_core, + deploy_list, + check_update, + install_update + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f56a237..f6ab3dc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -27,6 +27,7 @@ "bundle": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, "externalBin": [ "binaries/oab-mcp" ], @@ -40,5 +41,13 @@ "android": { "debugApplicationIdSuffix": ".debug" } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/openabdev/studio/releases/download/nightly/latest.json" + ], + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYzNkY5ODFCNDhERjNERDQKUldUVVBkOUlHNWh2OCtpMDRBdXVSSUpSTnVxSWdFUm9aNWRubVg4K29aQjk2Q25nZzhMUVArMEIK" + } } }