From 2129056803b4cc53fd6c2b096d5f01b157f1f550 Mon Sep 17 00:00:00 2001 From: ApocDev Date: Wed, 1 Jul 2026 09:27:52 -0400 Subject: [PATCH 1/2] fix(desktop): use the built-in updater/process plugins instead of custom commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom updater_check/updater_install commands could never work from the window's content: it's served over HTTP (remote to Tauri), and custom commands have no ACL permission to grant remote content, so invoke() was silently denied. Switch to the standard tauri-plugin-updater + tauri-plugin-process, driven from the web UI via their JS APIs (check / downloadAndInstall / relaunch), lazily imported behind window.isTauri so the browser build never loads them. Grant updater:default + process:default in the capability (which already allows the localhost remote URL) — plugin commands *do* have permissions, so the ACL can authorize them. Enable devtools in release so webview/ACL errors are inspectable while the shell is young. --- app/package.json | 2 + app/pnpm-lock.yaml | 20 ++++ app/src-tauri/Cargo.lock | 13 ++- app/src-tauri/Cargo.toml | 5 +- app/src-tauri/capabilities/default.json | 2 + app/src-tauri/src/lib.rs | 122 +++--------------------- app/src/lib/updater.ts | 66 ++++++++----- 7 files changed, 94 insertions(+), 136 deletions(-) diff --git a/app/package.json b/app/package.json index bb2076c..3acb5c3 100644 --- a/app/package.json +++ b/app/package.json @@ -41,6 +41,8 @@ "@tanstack/react-table": "latest", "@tanstack/router-plugin": "^1.132.0", "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "ai": "^6.0.204", "better-sqlite3": "^12.6.2", "class-variance-authority": "^0.7.1", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 6f486f6..2221322 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -80,6 +80,12 @@ importers: '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.1 + version: 2.10.1 ai: specifier: ^6.0.204 version: 6.0.204(zod@4.4.3) @@ -2961,6 +2967,12 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -8117,6 +8129,14 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 30295cb..856c7f3 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.2.0" +version = "0.4.3" dependencies = [ "log", "serde", @@ -86,6 +86,7 @@ dependencies = [ "tauri-build", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-updater", @@ -4052,6 +4053,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.5" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index d40bf07..5cf0fcf 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -21,9 +21,12 @@ tauri-build = { version = "2.6.3", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.11.3", features = [] } +# `devtools` enables right-click → Inspect in release too — useful while the desktop +# shell is young (e.g. to see updater/ACL errors from the webview console). +tauri = { version = "2.11.3", features = ["devtools"] } tauri-plugin-log = "2" tauri-plugin-opener = "2" +tauri-plugin-process = "2" tauri-plugin-shell = "2" tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 5ab5de9..c0c5458 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -8,6 +8,8 @@ }, "permissions": [ "core:default", + "updater:default", + "process:default", { "identifier": "shell:allow-execute", "allow": [{ "name": "binaries/node", "sidecar": true, "args": true }] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index fe06644..6417a87 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -27,7 +27,6 @@ window.addEventListener('click', function (e) { "#; use std::sync::Mutex; -use tauri_plugin_updater::UpdaterExt; #[cfg(not(debug_assertions))] use tauri::path::BaseDirectory; #[cfg(not(debug_assertions))] @@ -92,101 +91,10 @@ fn open_main_window(app: &tauri::AppHandle) { } } -/// Holds the update found by `updater_check` so `updater_install` can consume it. -struct PendingUpdate(Mutex>); - -/// Update metadata handed to the web UI (which renders its own toast + changelog). -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateInfo { - version: String, - current_version: String, - notes: Option, - date: Option, -} - -/// Download progress, streamed to the UI over a channel so it can show a bar. -#[derive(Clone, serde::Serialize)] -#[serde(tag = "event", content = "data", rename_all = "camelCase")] -enum DownloadEvent { - Progress { - chunk_length: usize, - content_length: Option, - }, - Finished, -} - -/// Check GitHub for a newer release. Returns its metadata (and stashes the pending -/// update for `updater_install`), or `null` if current / the check failed. The web UI -/// calls this on launch, guarded by `window.isTauri`, so a plain browser never does. -#[tauri::command] -async fn updater_check( - app: tauri::AppHandle, - pending: tauri::State<'_, PendingUpdate>, -) -> Result, String> { - log::info!("updater_check: querying for updates"); - let updater = app.updater().map_err(|e| { - log::error!("updater_check: updater unavailable: {e}"); - e.to_string() - })?; - let update = updater.check().await.map_err(|e| { - log::error!("updater_check: check failed: {e}"); - e.to_string() - })?; - match &update { - Some(u) => log::info!("updater_check: update available: {}", u.version), - None => log::info!("updater_check: up to date"), - } - let info = update.as_ref().map(|u| UpdateInfo { - version: u.version.clone(), - current_version: u.current_version.clone(), - notes: u.body.clone(), - date: u.date.map(|d| d.to_string()), - }); - *pending.0.lock().unwrap() = update; - Ok(info) -} - -/// Download + install the pending update (streaming progress), then relaunch. The -/// signature verifies against the baked-in public key inside `download_and_install`. -#[tauri::command] -async fn updater_install( - app: tauri::AppHandle, - pending: tauri::State<'_, PendingUpdate>, - on_event: tauri::ipc::Channel, -) -> Result<(), String> { - let update = pending - .0 - .lock() - .unwrap() - .take() - .ok_or_else(|| "no pending update".to_string())?; - let on_finish = on_event.clone(); - update - .download_and_install( - move |chunk_length, content_length| { - let _ = on_event.send(DownloadEvent::Progress { - chunk_length, - content_length, - }); - }, - move || { - let _ = on_finish.send(DownloadEvent::Finished); - }, - ) - .await - .map_err(|e| e.to_string())?; - // Kill the node sidecar before relaunching; restart() may not run the Exit - // handler, and a lingering server would hold port 34115 and break the new - // instance's own sidecar. - #[cfg(not(debug_assertions))] - if let Some(state) = app.try_state::() { - if let Some(child) = state.0.lock().unwrap().take() { - let _ = child.kill(); - } - } - app.restart() -} +// The self-updater is the standard tauri-plugin-updater + tauri-plugin-process, +// driven from the web UI via their JS APIs (guarded by `window.isTauri`). The window +// loads the app over HTTP, so that content is "remote" to Tauri and the capability +// grants it `updater:default` + `process:default` (see capabilities/default.json). #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -217,18 +125,16 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) - .manage(PendingUpdate(Mutex::new(None))) - .invoke_handler(tauri::generate_handler![updater_check, updater_install]) .setup(|app| { - // Log in every build (stdout + the OS log dir), so the updater path — the - // one place that uses Tauri IPC — can be diagnosed from a terminal even on - // a release, where the webview console isn't available. - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } // Bundled build: start the server via the vendored node sidecar. The data // dir is the per-OS app-data dir; migrations + mod source are bundled @@ -257,8 +163,8 @@ pub fn run() { tauri::async_runtime::spawn(async move { while rx.recv().await.is_some() {} }); } - // The web UI drives the update check (calls `updater_check` on launch when - // it detects it's inside the desktop shell), so nothing to spawn here. + // The web UI drives the update via the updater/process plugins on launch + // (guarded by window.isTauri), so nothing to spawn here. // Wait for the server off the main thread, then open the window on it. let handle = app.handle().clone(); diff --git a/app/src/lib/updater.ts b/app/src/lib/updater.ts index 4817d29..095fd63 100644 --- a/app/src/lib/updater.ts +++ b/app/src/lib/updater.ts @@ -1,12 +1,13 @@ -// Client for the desktop shell's self-updater. The updater logic lives in Rust -// (`app/src-tauri`: the `updater_check` / `updater_install` commands + a signature -// check); this module just calls them when we detect we're running inside the Tauri -// window. In a plain browser it's inert — except for a `?mockUpdate=` dev switch that -// fakes an update so the toast + dialog can be built and reviewed in `vp dev` without -// a bundled build. +// Client for the desktop shell's self-updater. It drives the standard +// tauri-plugin-updater + tauri-plugin-process via their JS APIs when we detect we're +// inside the Tauri window. In a plain browser it's inert — except for a `?mockUpdate=` +// dev switch that fakes an update so the toast + dialog can be built and reviewed in +// `vp dev` without a bundled build. // -// `@tauri-apps/api` is imported dynamically and only under `isTauri()`, so the browser -// build / web deploy never loads it. +// The plugin JS is imported dynamically and only under `isTauri()`, so the browser +// build / web deploy never loads it — no hard Tauri dependency in the web runtime. + +import type { Update } from "@tauri-apps/plugin-updater"; export interface UpdateInfo { version: string; @@ -69,19 +70,30 @@ export function mockUpdate(): UpdateInfo | null { }; } +// The pending Update from the last check, so installUpdate can download + install it. +let pending: Update | null = null; + /** Check once for a newer release. Returns metadata, or null when up to date / not in * the desktop shell / the check failed. */ export async function checkForUpdate(): Promise { const mock = mockUpdate(); if (mock) return mock; if (!isTauri()) return null; - const { invoke } = await import("@tauri-apps/api/core"); - return await invoke("updater_check"); + const { check } = await import("@tauri-apps/plugin-updater"); + const update = await check(); + pending = update; + if (!update) return null; + return { + version: update.version, + currentVersion: update.currentVersion, + notes: update.body ?? null, + date: update.date ?? null, + }; } /** Download + install the pending update, reporting progress as a 0..1 fraction (or - * null while the total size is unknown). In the real shell the app relaunches when - * done, so this may never resolve; in mock mode it simulates a few seconds. */ + * null while the total size is unknown), then relaunch. In the real shell the app + * relaunches when done; in mock mode it simulates a few seconds. */ export async function installUpdate(onProgress: (fraction: number | null) => void): Promise { if (mockUpdate()) { for (let p = 0; p <= 1; p += 0.04) { @@ -91,21 +103,23 @@ export async function installUpdate(onProgress: (fraction: number | null) => voi onProgress(1); return; } - const { invoke, Channel } = await import("@tauri-apps/api/core"); - type Msg = - | { event: "progress"; data: { chunkLength: number; contentLength: number | null } } - | { event: "finished" }; - const channel = new Channel(); + if (!pending) throw new Error("no pending update"); let downloaded = 0; let total = 0; - channel.onmessage = (msg) => { - if (msg.event === "progress") { - if (msg.data.contentLength) total = msg.data.contentLength; - downloaded += msg.data.chunkLength; - onProgress(total ? Math.min(downloaded / total, 1) : null); - } else if (msg.event === "finished") { - onProgress(1); + await pending.downloadAndInstall((event) => { + switch (event.event) { + case "Started": + total = event.data.contentLength ?? 0; + break; + case "Progress": + downloaded += event.data.chunkLength; + onProgress(total ? Math.min(downloaded / total, 1) : null); + break; + case "Finished": + onProgress(1); + break; } - }; - await invoke("updater_install", { onEvent: channel }); + }); + const { relaunch } = await import("@tauri-apps/plugin-process"); + await relaunch(); } From 09e3e593281291dd753bd92aba5e2049ba5b4de1 Mon Sep 17 00:00:00 2001 From: ApocDev Date: Wed, 1 Jul 2026 09:29:33 -0400 Subject: [PATCH 2/2] chore(desktop): log the updater check result for diagnosability --- app/src/components/update-prompt.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/components/update-prompt.tsx b/app/src/components/update-prompt.tsx index 51aaf7f..1e1f39b 100644 --- a/app/src/components/update-prompt.tsx +++ b/app/src/components/update-prompt.tsx @@ -43,7 +43,10 @@ export function UpdatePrompt() { if (checked.current) return; // launch-only; no polling checked.current = true; checkForUpdate() - .then((u) => u && setUpdate(u)) + .then((u) => { + console.info("[updater] check:", u ? `update ${u.version} available` : "up to date"); + if (u) setUpdate(u); + }) .catch((err) => console.error("[updater] check failed", err)); }, []);