Skip to content

Commit fdc9800

Browse files
authored
fix(desktop): use the built-in updater/process plugins (fixes the ACL dead end) (#63)
* fix(desktop): use the built-in updater/process plugins instead of custom commands 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. * chore(desktop): log the updater check result for diagnosability
1 parent a047e00 commit fdc9800

8 files changed

Lines changed: 98 additions & 137 deletions

File tree

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
"@tanstack/react-table": "latest",
4242
"@tanstack/router-plugin": "^1.132.0",
4343
"@tauri-apps/api": "^2.11.1",
44+
"@tauri-apps/plugin-process": "^2.3.1",
45+
"@tauri-apps/plugin-updater": "^2.10.1",
4446
"ai": "^6.0.204",
4547
"better-sqlite3": "^12.6.2",
4648
"class-variance-authority": "^0.7.1",

app/pnpm-lock.yaml

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/Cargo.lock

Lines changed: 12 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ tauri-build = { version = "2.6.3", features = [] }
2121
serde_json = "1.0"
2222
serde = { version = "1.0", features = ["derive"] }
2323
log = "0.4"
24-
tauri = { version = "2.11.3", features = [] }
24+
# `devtools` enables right-click → Inspect in release too — useful while the desktop
25+
# shell is young (e.g. to see updater/ACL errors from the webview console).
26+
tauri = { version = "2.11.3", features = ["devtools"] }
2527
tauri-plugin-log = "2"
2628
tauri-plugin-opener = "2"
29+
tauri-plugin-process = "2"
2730
tauri-plugin-shell = "2"
2831
tauri-plugin-single-instance = "2"
2932
tauri-plugin-updater = "2"

app/src-tauri/capabilities/default.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
},
99
"permissions": [
1010
"core:default",
11+
"updater:default",
12+
"process:default",
1113
{
1214
"identifier": "shell:allow-execute",
1315
"allow": [{ "name": "binaries/node", "sidecar": true, "args": true }]

app/src-tauri/src/lib.rs

Lines changed: 14 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ window.addEventListener('click', function (e) {
2727
"#;
2828

2929
use std::sync::Mutex;
30-
use tauri_plugin_updater::UpdaterExt;
3130
#[cfg(not(debug_assertions))]
3231
use tauri::path::BaseDirectory;
3332
#[cfg(not(debug_assertions))]
@@ -92,101 +91,10 @@ fn open_main_window(app: &tauri::AppHandle) {
9291
}
9392
}
9493

95-
/// Holds the update found by `updater_check` so `updater_install` can consume it.
96-
struct PendingUpdate(Mutex<Option<tauri_plugin_updater::Update>>);
97-
98-
/// Update metadata handed to the web UI (which renders its own toast + changelog).
99-
#[derive(serde::Serialize)]
100-
#[serde(rename_all = "camelCase")]
101-
struct UpdateInfo {
102-
version: String,
103-
current_version: String,
104-
notes: Option<String>,
105-
date: Option<String>,
106-
}
107-
108-
/// Download progress, streamed to the UI over a channel so it can show a bar.
109-
#[derive(Clone, serde::Serialize)]
110-
#[serde(tag = "event", content = "data", rename_all = "camelCase")]
111-
enum DownloadEvent {
112-
Progress {
113-
chunk_length: usize,
114-
content_length: Option<u64>,
115-
},
116-
Finished,
117-
}
118-
119-
/// Check GitHub for a newer release. Returns its metadata (and stashes the pending
120-
/// update for `updater_install`), or `null` if current / the check failed. The web UI
121-
/// calls this on launch, guarded by `window.isTauri`, so a plain browser never does.
122-
#[tauri::command]
123-
async fn updater_check(
124-
app: tauri::AppHandle,
125-
pending: tauri::State<'_, PendingUpdate>,
126-
) -> Result<Option<UpdateInfo>, String> {
127-
log::info!("updater_check: querying for updates");
128-
let updater = app.updater().map_err(|e| {
129-
log::error!("updater_check: updater unavailable: {e}");
130-
e.to_string()
131-
})?;
132-
let update = updater.check().await.map_err(|e| {
133-
log::error!("updater_check: check failed: {e}");
134-
e.to_string()
135-
})?;
136-
match &update {
137-
Some(u) => log::info!("updater_check: update available: {}", u.version),
138-
None => log::info!("updater_check: up to date"),
139-
}
140-
let info = update.as_ref().map(|u| UpdateInfo {
141-
version: u.version.clone(),
142-
current_version: u.current_version.clone(),
143-
notes: u.body.clone(),
144-
date: u.date.map(|d| d.to_string()),
145-
});
146-
*pending.0.lock().unwrap() = update;
147-
Ok(info)
148-
}
149-
150-
/// Download + install the pending update (streaming progress), then relaunch. The
151-
/// signature verifies against the baked-in public key inside `download_and_install`.
152-
#[tauri::command]
153-
async fn updater_install(
154-
app: tauri::AppHandle,
155-
pending: tauri::State<'_, PendingUpdate>,
156-
on_event: tauri::ipc::Channel<DownloadEvent>,
157-
) -> Result<(), String> {
158-
let update = pending
159-
.0
160-
.lock()
161-
.unwrap()
162-
.take()
163-
.ok_or_else(|| "no pending update".to_string())?;
164-
let on_finish = on_event.clone();
165-
update
166-
.download_and_install(
167-
move |chunk_length, content_length| {
168-
let _ = on_event.send(DownloadEvent::Progress {
169-
chunk_length,
170-
content_length,
171-
});
172-
},
173-
move || {
174-
let _ = on_finish.send(DownloadEvent::Finished);
175-
},
176-
)
177-
.await
178-
.map_err(|e| e.to_string())?;
179-
// Kill the node sidecar before relaunching; restart() may not run the Exit
180-
// handler, and a lingering server would hold port 34115 and break the new
181-
// instance's own sidecar.
182-
#[cfg(not(debug_assertions))]
183-
if let Some(state) = app.try_state::<ServerChild>() {
184-
if let Some(child) = state.0.lock().unwrap().take() {
185-
let _ = child.kill();
186-
}
187-
}
188-
app.restart()
189-
}
94+
// The self-updater is the standard tauri-plugin-updater + tauri-plugin-process,
95+
// driven from the web UI via their JS APIs (guarded by `window.isTauri`). The window
96+
// loads the app over HTTP, so that content is "remote" to Tauri and the capability
97+
// grants it `updater:default` + `process:default` (see capabilities/default.json).
19098

19199
#[cfg_attr(mobile, tauri::mobile_entry_point)]
192100
pub fn run() {
@@ -217,18 +125,16 @@ pub fn run() {
217125
.plugin(tauri_plugin_shell::init())
218126
.plugin(tauri_plugin_opener::init())
219127
.plugin(tauri_plugin_updater::Builder::new().build())
128+
.plugin(tauri_plugin_process::init())
220129
.plugin(tauri_plugin_window_state::Builder::default().build())
221-
.manage(PendingUpdate(Mutex::new(None)))
222-
.invoke_handler(tauri::generate_handler![updater_check, updater_install])
223130
.setup(|app| {
224-
// Log in every build (stdout + the OS log dir), so the updater path — the
225-
// one place that uses Tauri IPC — can be diagnosed from a terminal even on
226-
// a release, where the webview console isn't available.
227-
app.handle().plugin(
228-
tauri_plugin_log::Builder::default()
229-
.level(log::LevelFilter::Info)
230-
.build(),
231-
)?;
131+
if cfg!(debug_assertions) {
132+
app.handle().plugin(
133+
tauri_plugin_log::Builder::default()
134+
.level(log::LevelFilter::Info)
135+
.build(),
136+
)?;
137+
}
232138

233139
// Bundled build: start the server via the vendored node sidecar. The data
234140
// dir is the per-OS app-data dir; migrations + mod source are bundled
@@ -257,8 +163,8 @@ pub fn run() {
257163
tauri::async_runtime::spawn(async move { while rx.recv().await.is_some() {} });
258164
}
259165

260-
// The web UI drives the update check (calls `updater_check` on launch when
261-
// it detects it's inside the desktop shell), so nothing to spawn here.
166+
// The web UI drives the update via the updater/process plugins on launch
167+
// (guarded by window.isTauri), so nothing to spawn here.
262168

263169
// Wait for the server off the main thread, then open the window on it.
264170
let handle = app.handle().clone();

app/src/components/update-prompt.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export function UpdatePrompt() {
4343
if (checked.current) return; // launch-only; no polling
4444
checked.current = true;
4545
checkForUpdate()
46-
.then((u) => u && setUpdate(u))
46+
.then((u) => {
47+
console.info("[updater] check:", u ? `update ${u.version} available` : "up to date");
48+
if (u) setUpdate(u);
49+
})
4750
.catch((err) => console.error("[updater] check failed", err));
4851
}, []);
4952

app/src/lib/updater.ts

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
// Client for the desktop shell's self-updater. The updater logic lives in Rust
2-
// (`app/src-tauri`: the `updater_check` / `updater_install` commands + a signature
3-
// check); this module just calls them when we detect we're running inside the Tauri
4-
// window. In a plain browser it's inert — except for a `?mockUpdate=` dev switch that
5-
// fakes an update so the toast + dialog can be built and reviewed in `vp dev` without
6-
// a bundled build.
1+
// Client for the desktop shell's self-updater. It drives the standard
2+
// tauri-plugin-updater + tauri-plugin-process via their JS APIs when we detect we're
3+
// inside the Tauri window. In a plain browser it's inert — except for a `?mockUpdate=`
4+
// dev switch that fakes an update so the toast + dialog can be built and reviewed in
5+
// `vp dev` without a bundled build.
76
//
8-
// `@tauri-apps/api` is imported dynamically and only under `isTauri()`, so the browser
9-
// build / web deploy never loads it.
7+
// The plugin JS is imported dynamically and only under `isTauri()`, so the browser
8+
// build / web deploy never loads it — no hard Tauri dependency in the web runtime.
9+
10+
import type { Update } from "@tauri-apps/plugin-updater";
1011

1112
export interface UpdateInfo {
1213
version: string;
@@ -69,19 +70,30 @@ export function mockUpdate(): UpdateInfo | null {
6970
};
7071
}
7172

73+
// The pending Update from the last check, so installUpdate can download + install it.
74+
let pending: Update | null = null;
75+
7276
/** Check once for a newer release. Returns metadata, or null when up to date / not in
7377
* the desktop shell / the check failed. */
7478
export async function checkForUpdate(): Promise<UpdateInfo | null> {
7579
const mock = mockUpdate();
7680
if (mock) return mock;
7781
if (!isTauri()) return null;
78-
const { invoke } = await import("@tauri-apps/api/core");
79-
return await invoke<UpdateInfo | null>("updater_check");
82+
const { check } = await import("@tauri-apps/plugin-updater");
83+
const update = await check();
84+
pending = update;
85+
if (!update) return null;
86+
return {
87+
version: update.version,
88+
currentVersion: update.currentVersion,
89+
notes: update.body ?? null,
90+
date: update.date ?? null,
91+
};
8092
}
8193

8294
/** Download + install the pending update, reporting progress as a 0..1 fraction (or
83-
* null while the total size is unknown). In the real shell the app relaunches when
84-
* done, so this may never resolve; in mock mode it simulates a few seconds. */
95+
* null while the total size is unknown), then relaunch. In the real shell the app
96+
* relaunches when done; in mock mode it simulates a few seconds. */
8597
export async function installUpdate(onProgress: (fraction: number | null) => void): Promise<void> {
8698
if (mockUpdate()) {
8799
for (let p = 0; p <= 1; p += 0.04) {
@@ -91,21 +103,23 @@ export async function installUpdate(onProgress: (fraction: number | null) => voi
91103
onProgress(1);
92104
return;
93105
}
94-
const { invoke, Channel } = await import("@tauri-apps/api/core");
95-
type Msg =
96-
| { event: "progress"; data: { chunkLength: number; contentLength: number | null } }
97-
| { event: "finished" };
98-
const channel = new Channel<Msg>();
106+
if (!pending) throw new Error("no pending update");
99107
let downloaded = 0;
100108
let total = 0;
101-
channel.onmessage = (msg) => {
102-
if (msg.event === "progress") {
103-
if (msg.data.contentLength) total = msg.data.contentLength;
104-
downloaded += msg.data.chunkLength;
105-
onProgress(total ? Math.min(downloaded / total, 1) : null);
106-
} else if (msg.event === "finished") {
107-
onProgress(1);
109+
await pending.downloadAndInstall((event) => {
110+
switch (event.event) {
111+
case "Started":
112+
total = event.data.contentLength ?? 0;
113+
break;
114+
case "Progress":
115+
downloaded += event.data.chunkLength;
116+
onProgress(total ? Math.min(downloaded / total, 1) : null);
117+
break;
118+
case "Finished":
119+
onProgress(1);
120+
break;
108121
}
109-
};
110-
await invoke("updater_install", { onEvent: channel });
122+
});
123+
const { relaunch } = await import("@tauri-apps/plugin-process");
124+
await relaunch();
111125
}

0 commit comments

Comments
 (0)