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
2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions app/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions app/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
},
"permissions": [
"core:default",
"updater:default",
"process:default",
{
"identifier": "shell:allow-execute",
"allow": [{ "name": "binaries/node", "sidecar": true, "args": true }]
Expand Down
122 changes: 14 additions & 108 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down Expand Up @@ -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<Option<tauri_plugin_updater::Update>>);

/// 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<String>,
date: Option<String>,
}

/// 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<u64>,
},
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<Option<UpdateInfo>, 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<DownloadEvent>,
) -> 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::<ServerChild>() {
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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion app/src/components/update-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}, []);

Expand Down
66 changes: 40 additions & 26 deletions app/src/lib/updater.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<UpdateInfo | null> {
const mock = mockUpdate();
if (mock) return mock;
if (!isTauri()) return null;
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<UpdateInfo | null>("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<void> {
if (mockUpdate()) {
for (let p = 0; p <= 1; p += 0.04) {
Expand All @@ -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<Msg>();
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();
}
Loading