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