Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<div class="meta">
<span class="cluster" id="cluster-label"></span>
<span class="status" id="poll-status"></span>
<button class="update-btn" id="update-btn" hidden>檢查更新</button>
</div>
</header>
<main class="content">
Expand Down
74 changes: 71 additions & 3 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,17 @@ async function tick(): Promise<void> {
}
}

// 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 = <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
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<void> {
const invoke = (
globalThis as { __TAURI__?: { core?: { invoke?: (c: string) => Promise<unknown> } } }
).__TAURI__?.core?.invoke;
const invoke = tauriInvoke();
if (!invoke) return; // browser build — MockSource, no core
try {
await invoke("start_core");
Expand All @@ -102,13 +108,75 @@ async function startCore(): Promise<void> {
}
}

// 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<void> {
btn.disabled = true;
btn.textContent = "檢查中…";
try {
const info = await inv<UpdateInfo | null>("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<void> {
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<void> {
note("info", `OAB Studio ${BUILD} (built ${__BUILD_TIME__})`);
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);
Expand Down
25 changes: 25 additions & 0 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
57 changes: 56 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
}

/// 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<Option<UpdateInfo>, 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(
Expand All @@ -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");
}
9 changes: 9 additions & 0 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"externalBin": [
"binaries/oab-mcp"
],
Expand All @@ -40,5 +41,13 @@
"android": {
"debugApplicationIdSuffix": ".debug"
}
},
"plugins": {
"updater": {
"endpoints": [
"https://github.com/openabdev/studio/releases/download/nightly/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYzNkY5ODFCNDhERjNERDQKUldUVVBkOUlHNWh2OCtpMDRBdXVSSUpSTnVxSWdFUm9aNWRubVg4K29aQjk2Q25nZzhMUVArMEIK"
}
}
}
Loading