diff --git a/.github/scripts/generate-portable-update.mjs b/.github/scripts/generate-portable-update.mjs new file mode 100644 index 0000000..b7980db --- /dev/null +++ b/.github/scripts/generate-portable-update.mjs @@ -0,0 +1,71 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function readOptions(args) { + const options = new Map(); + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (!name?.startsWith("--") || value === undefined) { + throw new Error(`invalid argument near ${name ?? "end of command"}`); + } + options.set(name.slice(2), value); + } + return options; +} + +function required(options, name) { + const value = options.get(name)?.trim(); + if (!value) throw new Error(`--${name} is required`); + return value; +} + +export function generatePortableUpdate({ version, repository, assetName, signature }) { + const normalizedVersion = version.replace(/^v/, ""); + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(normalizedVersion)) { + throw new Error(`invalid semantic version: ${normalizedVersion}`); + } + if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) { + throw new Error(`invalid GitHub repository: ${repository}`); + } + if (!assetName || basename(assetName) !== assetName) { + throw new Error("asset name must be a file name"); + } + if (!signature.trim()) throw new Error("signature is required"); + + return { + version: normalizedVersion, + notes: `Cursor BYOK v${normalizedVersion}`, + pub_date: new Date().toISOString(), + platforms: { + "windows-x86_64": { + signature: signature.trim(), + url: `https://github.com/${repository}/releases/download/v${normalizedVersion}/${encodeURIComponent(assetName)}`, + }, + }, + }; +} + +async function main() { + const options = readOptions(process.argv.slice(2)); + const version = required(options, "version"); + const repository = required(options, "repository"); + const asset = required(options, "asset"); + const signaturePath = resolve(required(options, "signature")); + const output = resolve(required(options, "output")); + const manifest = generatePortableUpdate({ + version, + repository, + assetName: basename(asset), + signature: await readFile(signaturePath, "utf8"), + }); + await writeFile(output, `${JSON.stringify(manifest, null, 2)}\n`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/.github/scripts/generate-portable-update.test.mjs b/.github/scripts/generate-portable-update.test.mjs new file mode 100644 index 0000000..1bcd223 --- /dev/null +++ b/.github/scripts/generate-portable-update.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { generatePortableUpdate } from "./generate-portable-update.mjs"; + +test("generates a signed Windows portable updater manifest", () => { + const manifest = generatePortableUpdate({ + version: "v1.2.3-beta.1", + repository: "owner/repository", + assetName: "cursor-byok-1.2.3-beta.1-windows-amd64.zip", + signature: "signed-payload\n", + }); + + assert.equal(manifest.version, "1.2.3-beta.1"); + assert.deepEqual(Object.keys(manifest.platforms), ["windows-x86_64"]); + assert.equal(manifest.platforms["windows-x86_64"].signature, "signed-payload"); + assert.equal( + manifest.platforms["windows-x86_64"].url, + "https://github.com/owner/repository/releases/download/v1.2.3-beta.1/cursor-byok-1.2.3-beta.1-windows-amd64.zip", + ); +}); + +test("rejects invalid inputs", () => { + assert.throws(() => generatePortableUpdate({ + version: "latest", + repository: "owner/repository", + assetName: "update.zip", + signature: "signature", + }), /semantic version/); + assert.throws(() => generatePortableUpdate({ + version: "1.2.3", + repository: "owner/repository", + assetName: "../update.zip", + signature: "signature", + }), /file name/); + assert.throws(() => generatePortableUpdate({ + version: "1.2.3", + repository: "owner/repository", + assetName: "update.zip", + signature: " ", + }), /signature/); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a55b8e8..92fd50f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,14 +158,27 @@ jobs: mkdir -p legacy-update tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop - - name: Package legacy Windows updater asset + - name: Package and sign legacy Windows updater asset if: matrix.platform == 'windows-x86_64' shell: pwsh env: VERSION: ${{ needs.prepare.outputs.version }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | New-Item -ItemType Directory -Force legacy-update | Out-Null - Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip" + $asset = "legacy-update/cursor-byok-$env:VERSION-windows-amd64.zip" + Compress-Archive -LiteralPath target/release/cursor-byok-desktop.exe -DestinationPath $asset + Push-Location apps/desktop + npm exec tauri signer sign -- "../../$asset" + Pop-Location + $entries = @(tar -tf $asset) + if ($entries.Count -ne 1 -or [System.IO.Path]::GetFileName($entries[0]) -ne 'cursor-byok-desktop.exe') { + throw "Windows updater archive must contain only cursor-byok-desktop.exe" + } + if (!(Test-Path "$asset.sig")) { + throw "Windows updater archive signature was not generated" + } - name: Package legacy macOS updater asset if: contains(matrix.platform, 'macos') @@ -213,6 +226,20 @@ jobs: --output legacy-update/update.json \ --notes "Cursor BYOK v${VERSION}" + - name: Generate signed Windows portable update manifest + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + asset="cursor-byok-${VERSION}-windows-amd64.zip" + test -f "legacy-update/${asset}" + test -f "legacy-update/${asset}.sig" + node .github/scripts/generate-portable-update.mjs \ + --version "${VERSION}" \ + --repository "${GITHUB_REPOSITORY}" \ + --asset "${asset}" \ + --signature "legacy-update/${asset}.sig" \ + --output legacy-update/portable-latest.json + - name: Normalize Tauri updater download URLs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index effc024..9096523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1188,12 +1188,15 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "tokio", "tokio-util", "tracing", "tracing-appender", "tracing-subscriber", "url", + "windows-sys 0.61.2", + "zip", ] [[package]] diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b691673..4972ca3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -8,7 +8,7 @@ "dev": "vite", "typecheck": "tsc --noEmit", "typecheck:node": "tsc --noEmit -p tsconfig.node.json", - "i18n:scan": "STATIC_I18N_SCAN=true vite build", + "i18n:scan": "cross-env STATIC_I18N_SCAN=true vite build", "build": "vite build", "build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts", "check": "npm run typecheck && npm run typecheck:node && npm run build", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7f6328e..9d172fc 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -25,9 +25,14 @@ tauri-plugin-opener = "2" tauri-plugin-autostart = "2" tauri-plugin-process = "2" tauri-plugin-updater = "2" +tempfile = "3" tokio = { version = "1", features = ["time"] } tokio-util = "0.7" tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } url = "2" +zip = { version = "4", default-features = false, features = ["deflate"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading"] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index da09dc1..dcb6da1 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,5 +1,9 @@ fn main() { - let manifest = tauri_build::AppManifest::new().commands(&["open_terminal_with_command"]); + let manifest = tauri_build::AppManifest::new().commands(&[ + "open_terminal_with_command", + "check_portable_update", + "install_portable_update", + ]); tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest)) .expect("failed to build Tauri application") } diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 1954d26..c34b30e 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -18,6 +18,8 @@ "core:window:allow-close", "core:app:allow-set-dock-visibility", "allow-open-terminal-with-command", + "allow-check-portable-update", + "allow-install-portable-update", "clipboard-manager:allow-write-text", "autostart:default", "process:allow-restart", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/check_portable_update.toml b/apps/desktop/src-tauri/permissions/autogenerated/check_portable_update.toml new file mode 100644 index 0000000..aea0675 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/check_portable_update.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-check-portable-update" +description = "Enables the check_portable_update command without any pre-configured scope." +commands.allow = ["check_portable_update"] + +[[permission]] +identifier = "deny-check-portable-update" +description = "Denies the check_portable_update command without any pre-configured scope." +commands.deny = ["check_portable_update"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/install_portable_update.toml b/apps/desktop/src-tauri/permissions/autogenerated/install_portable_update.toml new file mode 100644 index 0000000..edf77fb --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/install_portable_update.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-install-portable-update" +description = "Enables the install_portable_update command without any pre-configured scope." +commands.allow = ["install_portable_update"] + +[[permission]] +identifier = "deny-install-portable-update" +description = "Denies the install_portable_update command without any pre-configured scope." +commands.deny = ["install_portable_update"] diff --git a/apps/desktop/src-tauri/src/desktop.rs b/apps/desktop/src-tauri/src/desktop.rs index 6afa44c..c700bea 100644 --- a/apps/desktop/src-tauri/src/desktop.rs +++ b/apps/desktop/src-tauri/src/desktop.rs @@ -177,7 +177,11 @@ pub fn run() -> ExitCode { let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG); let app = tauri::Builder::default() - .invoke_handler(tauri::generate_handler![open_terminal_with_command]) + .invoke_handler(tauri::generate_handler![ + open_terminal_with_command, + crate::update::check_portable_update, + crate::update::install_portable_update, + ]) .plugin(tauri_plugin_single_instance::init(|app, args, _| { if !args.iter().any(|arg| arg == AUTOSTART_ARG) { tray::show_main_window(app); @@ -245,6 +249,7 @@ pub fn run() -> ExitCode { window.set_focus()?; } tray::create(app)?; + crate::update::signal_ready_if_requested()?; Ok(()) }) .build(tauri::generate_context!()); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 14e5164..cef6375 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4,5 +4,11 @@ mod frontend; mod resource_limits; mod startup; mod tray; +mod update; -pub use desktop::run; +pub fn run() -> std::process::ExitCode { + if let Some(exit_code) = update::run_replacement_if_requested() { + return exit_code; + } + desktop::run() +} diff --git a/apps/desktop/src-tauri/src/update/mod.rs b/apps/desktop/src-tauri/src/update/mod.rs new file mode 100644 index 0000000..a6f0e62 --- /dev/null +++ b/apps/desktop/src-tauri/src/update/mod.rs @@ -0,0 +1,269 @@ +use std::{ + fs::{self, OpenOptions}, + io::{Cursor, Read, Write}, + path::{Path, PathBuf}, + process::{Command, ExitCode}, +}; + +use serde::Serialize; +use tauri::AppHandle; + +#[cfg(target_os = "windows")] +use tauri_plugin_updater::UpdaterExt; + +#[cfg(target_os = "windows")] +mod replacement; + +const PORTABLE_UPDATE_ENDPOINT: &str = + "https://github.com/leookun/cursor-byok/releases/latest/download/portable-latest.json"; +const WINDOWS_PAYLOAD_NAME: &str = "cursor-byok-desktop.exe"; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PortableUpdateInfo { + version: String, +} + +pub fn run_replacement_if_requested() -> Option { + #[cfg(target_os = "windows")] + { + match replacement::request_from_args() { + Ok(Some(request)) => return Some(replacement::run(request)), + Ok(None) => {} + Err(error) => { + eprintln!("invalid portable update replacement request: {error}"); + return Some(ExitCode::FAILURE); + } + } + } + None +} + +pub(crate) fn signal_ready_if_requested() -> std::io::Result<()> { + #[cfg(target_os = "windows")] + if let Some(path) = replacement::ready_marker_from_args() { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, b"ready")?; + } + Ok(()) +} + +#[tauri::command] +pub(crate) async fn check_portable_update( + app: AppHandle, +) -> Result, String> { + #[cfg(target_os = "windows")] + { + let update = portable_update(&app).await?; + return Ok(update.map(|update| PortableUpdateInfo { + version: update.version, + })); + } + #[cfg(not(target_os = "windows"))] + { + let _ = app; + Err("portable updates are only supported on Windows".into()) + } +} + +#[tauri::command] +pub(crate) async fn install_portable_update( + app: AppHandle, + expected_version: String, +) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + let update = portable_update(&app) + .await? + .ok_or_else(|| "the selected update is no longer available".to_string())?; + if update.version != expected_version { + return Err(format!( + "available update changed from {expected_version} to {}", + update.version + )); + } + + let target = std::env::current_exe() + .map_err(|error| format!("failed to locate the running executable: {error}"))?; + ensure_target_writable(&target) + .map_err(|error| format!("the application directory is not writable: {error}"))?; + + let bytes = update + .download(|_, _| {}, || {}) + .await + .map_err(|error| format!("failed to download or verify the update: {error}"))?; + let payload = extract_windows_payload(&bytes) + .map_err(|error| format!("invalid Windows update archive: {error}"))?; + let staged = stage_payload(&target, &payload) + .map_err(|error| format!("failed to stage the update: {error}"))?; + + let handshake = staged.with_extension("started"); + let _ = fs::remove_file(&handshake); + let mut replacement = Command::new(&staged) + .arg("--apply-portable-update") + .arg("--update-target") + .arg(&target) + .arg("--update-wait-pid") + .arg(std::process::id().to_string()) + .arg("--update-handshake") + .arg(&handshake) + .spawn() + .map_err(|error| format!("failed to start the update replacement process: {error}"))?; + wait_for_replacement_start(&mut replacement, &handshake).await?; + + app.exit(0); + Ok(()) + } + #[cfg(not(target_os = "windows"))] + { + let _ = (app, expected_version); + Err("portable updates are only supported on Windows".into()) + } +} + +#[cfg(target_os = "windows")] +async fn portable_update(app: &AppHandle) -> Result, String> { + let endpoint = PORTABLE_UPDATE_ENDPOINT + .parse() + .map_err(|error| format!("invalid portable update endpoint: {error}"))?; + let updater = app + .updater_builder() + .endpoints(vec![endpoint]) + .map_err(|error| format!("failed to configure the updater: {error}"))? + .build() + .map_err(|error| format!("failed to initialize the updater: {error}"))?; + updater + .check() + .await + .map_err(|error| format!("failed to check for updates: {error}")) +} + +#[cfg(target_os = "windows")] +async fn wait_for_replacement_start( + child: &mut std::process::Child, + handshake: &Path, +) -> Result<(), String> { + let wait = async { + loop { + if handshake.is_file() { + return Ok(()); + } + if let Some(status) = child + .try_wait() + .map_err(|error| format!("failed to inspect replacement process: {error}"))? + { + return Err(format!( + "update replacement process exited before it was ready: {status}" + )); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + }; + let result = match tokio::time::timeout(std::time::Duration::from_secs(5), wait).await { + Ok(result) => result, + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("update replacement process did not become ready".into()); + } + }; + if result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + result +} + +fn ensure_target_writable(target: &Path) -> std::io::Result<()> { + let parent = target + .parent() + .ok_or_else(|| std::io::Error::other("application executable has no parent directory"))?; + let probe = parent.join(format!( + ".cursor-byok-update-write-test-{}", + std::process::id() + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&probe)?; + file.write_all(b"test")?; + drop(file); + fs::remove_file(probe) +} + +fn extract_windows_payload(bytes: &[u8]) -> Result, String> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|error| format!("failed to open ZIP: {error}"))?; + if archive.len() != 1 { + return Err("archive must contain exactly one file".into()); + } + let mut entry = archive + .by_index(0) + .map_err(|error| format!("failed to read ZIP entry: {error}"))?; + let name = Path::new(entry.name()) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "archive entry has an invalid file name".to_string())?; + if name != WINDOWS_PAYLOAD_NAME || entry.is_dir() { + return Err(format!( + "expected {WINDOWS_PAYLOAD_NAME}, found {}", + entry.name() + )); + } + let mut payload = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut payload) + .map_err(|error| format!("failed to extract executable: {error}"))?; + if payload.len() < 2 || &payload[..2] != b"MZ" { + return Err("payload is not a Windows executable".into()); + } + Ok(payload) +} + +fn stage_payload(target: &Path, payload: &[u8]) -> std::io::Result { + let directory = tempfile::Builder::new() + .prefix("cursor-byok-portable-update-") + .tempdir()?; + let name = target + .file_name() + .ok_or_else(|| std::io::Error::other("application executable has no file name"))?; + let path = directory.path().join(name); + fs::write(&path, payload)?; + let _ = directory.keep(); + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn update_zip(name: &str, payload: &[u8]) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + { + let mut archive = zip::ZipWriter::new(&mut bytes); + archive + .start_file(name, zip::write::SimpleFileOptions::default()) + .unwrap(); + archive.write_all(payload).unwrap(); + archive.finish().unwrap(); + } + bytes.into_inner() + } + + #[test] + fn extracts_the_single_expected_windows_executable() { + let bytes = update_zip(WINDOWS_PAYLOAD_NAME, b"MZpayload"); + assert_eq!(extract_windows_payload(&bytes).unwrap(), b"MZpayload"); + } + + #[test] + fn rejects_unexpected_or_non_executable_payloads() { + let wrong_name = update_zip("other.exe", b"MZpayload"); + assert!(extract_windows_payload(&wrong_name).is_err()); + let wrong_content = update_zip(WINDOWS_PAYLOAD_NAME, b"not an executable"); + assert!(extract_windows_payload(&wrong_content).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/update/replacement.rs b/apps/desktop/src-tauri/src/update/replacement.rs new file mode 100644 index 0000000..cf2cb78 --- /dev/null +++ b/apps/desktop/src-tauri/src/update/replacement.rs @@ -0,0 +1,296 @@ +use std::{ + ffi::{OsStr, OsString}, + fs, io, + path::{Path, PathBuf}, + process::{Child, Command, ExitCode}, + thread, + time::{Duration, Instant}, +}; + +const APPLY_ARG: &str = "--apply-portable-update"; +const TARGET_ARG: &str = "--update-target"; +const PID_ARG: &str = "--update-wait-pid"; +const HANDSHAKE_ARG: &str = "--update-handshake"; +pub(super) const READY_ARG: &str = "--portable-update-ready"; +const PROCESS_WAIT_TIMEOUT: Duration = Duration::from_secs(30); +const READY_WAIT_TIMEOUT: Duration = Duration::from_secs(30); + +pub(super) struct ReplacementRequest { + target: PathBuf, + pid: u32, + handshake: PathBuf, +} + +pub(super) fn request_from_args() -> Result, String> { + let args = std::env::args_os().collect::>(); + if !args.iter().any(|arg| arg == APPLY_ARG) { + return Ok(None); + } + let target = PathBuf::from( + argument_value(&args, TARGET_ARG).ok_or_else(|| format!("{TARGET_ARG} is required"))?, + ); + let pid = argument_value(&args, PID_ARG) + .ok_or_else(|| format!("{PID_ARG} is required"))? + .to_string_lossy() + .parse::() + .map_err(|error| format!("invalid {PID_ARG}: {error}"))?; + let handshake = PathBuf::from( + argument_value(&args, HANDSHAKE_ARG) + .ok_or_else(|| format!("{HANDSHAKE_ARG} is required"))?, + ); + validate_target(&target).map_err(|error| error.to_string())?; + Ok(Some(ReplacementRequest { + target, + pid, + handshake, + })) +} + +pub(super) fn ready_marker_from_args() -> Option { + let args = std::env::args_os().collect::>(); + argument_value(&args, READY_ARG).map(PathBuf::from) +} + +fn argument_value<'a>(args: &'a [OsString], name: &str) -> Option<&'a OsStr> { + args.iter() + .position(|arg| arg == name) + .and_then(|index| args.get(index + 1)) + .map(OsString::as_os_str) +} + +fn validate_target(target: &Path) -> io::Result<()> { + if !target.is_absolute() || !target.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "update target must be an existing absolute file", + )); + } + let source_name = std::env::current_exe()? + .file_name() + .map(OsStr::to_os_string) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "updater has no file name"))?; + if target.file_name() != Some(source_name.as_os_str()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "update target file name does not match the updater", + )); + } + Ok(()) +} + +pub(super) fn run(request: ReplacementRequest) -> ExitCode { + match run_inner(request) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("portable update replacement failed: {error}"); + ExitCode::FAILURE + } + } +} + +fn run_inner(request: ReplacementRequest) -> io::Result<()> { + wait_for_process(request.pid, PROCESS_WAIT_TIMEOUT, &request.handshake)?; + remove_file_if_exists(&request.handshake)?; + let source = std::env::current_exe()?; + let backup = backup_path(&request.target); + let ready = source.with_extension("ready"); + remove_file_if_exists(&ready)?; + + if let Err(error) = install_staged(&source, &request.target, &backup) { + relaunch(&request.target); + return Err(error); + } + let mut child = match Command::new(&request.target) + .arg(READY_ARG) + .arg(&ready) + .spawn() + { + Ok(child) => child, + Err(error) => { + restore_backup(&request.target, &backup)?; + relaunch(&request.target); + return Err(error); + } + }; + + match wait_until_ready(&mut child, &ready, READY_WAIT_TIMEOUT) { + Ok(()) => { + let _ = fs::remove_file(&backup); + let _ = fs::remove_file(&ready); + Ok(()) + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + restore_backup(&request.target, &backup)?; + relaunch(&request.target); + Err(error) + } + } +} + +fn relaunch(target: &Path) { + if target.is_file() { + let _ = Command::new(target).spawn(); + } +} + +fn remove_file_if_exists(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn backup_path(target: &Path) -> PathBuf { + path_with_suffix(target, ".old") +} + +fn pending_path(target: &Path) -> PathBuf { + path_with_suffix(target, ".new") +} + +fn path_with_suffix(target: &Path, suffix: &str) -> PathBuf { + let mut name = target.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) +} + +fn install_staged(source: &Path, target: &Path, backup: &Path) -> io::Result<()> { + let pending = pending_path(target); + remove_file_if_exists(&pending)?; + fs::copy(source, &pending)?; + + let result = activate_pending(&pending, target, backup); + if result.is_err() { + let _ = fs::remove_file(&pending); + } + result +} + +fn activate_pending(pending: &Path, target: &Path, backup: &Path) -> io::Result<()> { + remove_file_if_exists(backup)?; + fs::rename(target, backup)?; + if let Err(error) = fs::rename(pending, target) { + if let Err(restore_error) = restore_backup(target, backup) { + return Err(io::Error::other(format!( + "failed to install update ({error}) and restore the original executable ({restore_error})" + ))); + } + return Err(error); + } + Ok(()) +} + +fn restore_backup(target: &Path, backup: &Path) -> io::Result<()> { + remove_file_if_exists(target)?; + fs::rename(backup, target) +} + +fn wait_until_ready(child: &mut Child, marker: &Path, timeout: Duration) -> io::Result<()> { + let deadline = Instant::now() + timeout; + loop { + if marker.is_file() { + return Ok(()); + } + if let Some(status) = child.try_wait()? { + return Err(io::Error::other(format!( + "updated application exited before startup completed: {status}" + ))); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "updated application did not report a successful startup", + )); + } + thread::sleep(Duration::from_millis(100)); + } +} + +#[cfg(windows)] +fn wait_for_process(pid: u32, timeout: Duration, handshake: &Path) -> io::Result<()> { + use windows_sys::Win32::{ + Foundation::{ + CloseHandle, GetLastError, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT, + }, + System::Threading::{OpenProcess, WaitForSingleObject}, + }; + + const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; + let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) }; + if handle.is_null() { + let error = unsafe { GetLastError() }; + return if error == ERROR_INVALID_PARAMETER { + fs::write(handshake, b"started") + } else { + Err(io::Error::from_raw_os_error(error as i32)) + }; + } + if let Err(error) = fs::write(handshake, b"started") { + unsafe { CloseHandle(handle) }; + return Err(error); + } + let milliseconds = timeout.as_millis().min(u32::MAX as u128) as u32; + let result = unsafe { WaitForSingleObject(handle, milliseconds) }; + unsafe { CloseHandle(handle) }; + match result { + WAIT_OBJECT_0 => Ok(()), + WAIT_TIMEOUT => Err(io::Error::new( + io::ErrorKind::TimedOut, + "running application did not exit before the update timeout", + )), + _ => Err(io::Error::last_os_error()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn staged_file_can_be_restored() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source.exe"); + let target = directory.path().join("target.exe"); + let backup = backup_path(&target); + fs::write(&source, b"new").unwrap(); + fs::write(&target, b"old").unwrap(); + + install_staged(&source, &target, &backup).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"new"); + assert_eq!(fs::read(&backup).unwrap(), b"old"); + + restore_backup(&target, &backup).unwrap(); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert!(!backup.exists()); + } + + #[test] + fn missing_pending_file_restores_original_after_backup() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.exe"); + let pending = pending_path(&target); + let backup = backup_path(&target); + fs::write(&target, b"old").unwrap(); + + assert!(activate_pending(&pending, &target, &backup).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert!(!backup.exists()); + } + + #[test] + fn missing_source_preserves_original_file() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("missing.exe"); + let target = directory.path().join("target.exe"); + let backup = backup_path(&target); + fs::write(&target, b"old").unwrap(); + + assert!(install_staged(&source, &target, &backup).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"old"); + assert!(!backup.exists()); + assert!(!pending_path(&target).exists()); + } +} diff --git a/apps/desktop/src/features/settings/AppLifecycleSettingsCard.tsx b/apps/desktop/src/features/settings/AppLifecycleSettingsCard.tsx index 7c14ea3..eff6f94 100644 --- a/apps/desktop/src/features/settings/AppLifecycleSettingsCard.tsx +++ b/apps/desktop/src/features/settings/AppLifecycleSettingsCard.tsx @@ -95,7 +95,8 @@ export function AppLifecycleSettingsCard() { const nextVersion = await updateStore.check(); message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本")); } catch (cause) { - message(cause instanceof Error ? cause.message : String(cause)); + const error = cause instanceof Error ? cause.message : String(cause); + message(t("检查更新失败:{error}", { error })); } }; @@ -103,7 +104,8 @@ export function AppLifecycleSettingsCard() { try { await updateStore.install(); } catch (cause) { - message(cause instanceof Error ? cause.message : String(cause)); + const error = cause instanceof Error ? cause.message : String(cause); + message(t("安装更新失败:{error}", { error })); } }; diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index 5f06481..c294ccf 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -606,7 +606,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 156, + "line": 158, "column": 27 } ] @@ -916,7 +916,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 152, + "line": 154, "column": 13 } ] @@ -1673,7 +1673,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 126, + "line": 128, "column": 17 } ] @@ -1725,7 +1725,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 151, + "line": 153, "column": 13 } ] @@ -2353,7 +2353,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 488, + "line": 486, "column": 43 } ] @@ -2365,7 +2365,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 149, + "line": 151, "column": 18 } ] @@ -2451,12 +2451,12 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 137, + "line": 139, "column": 18 }, { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 143, + "line": 145, "column": 16 } ] @@ -2680,7 +2680,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 483, + "line": 481, "column": 43 } ] @@ -2795,7 +2795,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 160, + "line": 162, "column": 37 } ] @@ -2834,7 +2834,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 420, + "line": 418, "column": 21 } ] @@ -2900,12 +2900,12 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 113, + "line": 115, "column": 18 }, { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 119, + "line": 121, "column": 16 } ] @@ -3194,6 +3194,20 @@ } ] }, + "92e26b27d5ea8f0e": { + "source": "检查更新失败:{error}", + "kind": "template", + "placeholders": [ + "error" + ], + "refs": [ + { + "file": "features/settings/AppLifecycleSettingsCard.tsx", + "line": 99, + "column": 15 + } + ] + }, "940a168911ade998": { "source": "每页条数", "kind": "text", @@ -3520,7 +3534,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 110, + "line": 112, "column": 29 } ] @@ -3532,12 +3546,12 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 125, + "line": 127, "column": 18 }, { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 131, + "line": 133, "column": 16 } ] @@ -3749,6 +3763,20 @@ } ] }, + "a80b53f8848e6d27": { + "source": "安装更新失败:{error}", + "kind": "template", + "placeholders": [ + "error" + ], + "refs": [ + { + "file": "features/settings/AppLifecycleSettingsCard.tsx", + "line": 108, + "column": 15 + } + ] + }, "a98585871c5313ff": { "source": "显示名称", "kind": "text", @@ -3814,7 +3842,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 156, + "line": 158, "column": 39 } ] @@ -3854,7 +3882,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 138, + "line": 140, "column": 17 } ] @@ -4577,7 +4605,7 @@ "refs": [ { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 114, + "line": 116, "column": 17 } ] @@ -5522,7 +5550,7 @@ }, { "file": "features/settings/AppLifecycleSettingsCard.tsx", - "line": 160, + "line": 162, "column": 25 } ] diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index d352612..83eaa21 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -221,6 +221,7 @@ "91aaf184cfc17ffd": "Overview", "91af6e57e7453fbe": "Add account", "92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.", + "92e26b27d5ea8f0e": "Failed to check for updates: {error}", "940a168911ade998": "Items per page", "945fb1c67eca8493": "Installing the plugin runtime", "946b3ffc02f026c0": "Delete this model?", @@ -259,6 +260,7 @@ "a748cc074f78de00": "View details", "a7617f42f898b2bf": "Use complete request URL", "a8036485f9227f2c": "Drag to reorder", + "a80b53f8848e6d27": "Failed to install update: {error}", "a98585871c5313ff": "Display name", "ab9084a640fbb864": "Deselect all", "abecab6701177721": "Launch at login enabled", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index 53ed05e..2c5a221 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -221,6 +221,7 @@ "91aaf184cfc17ffd": "数据概览", "91af6e57e7453fbe": "添加账号", "92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。", + "92e26b27d5ea8f0e": "检查更新失败:{error}", "940a168911ade998": "每页条数", "945fb1c67eca8493": "正在安装插件运行时", "946b3ffc02f026c0": "确定删除这个模型吗?", @@ -259,6 +260,7 @@ "a748cc074f78de00": "查看详情", "a7617f42f898b2bf": "使用完整请求地址", "a8036485f9227f2c": "拖动排序", + "a80b53f8848e6d27": "安装更新失败:{error}", "a98585871c5313ff": "显示名称", "ab9084a640fbb864": "全不选", "abecab6701177721": "已开启开机启动", diff --git a/apps/desktop/src/shared/native/appLifecycle.ts b/apps/desktop/src/shared/native/appLifecycle.ts index da7379c..e959ac6 100644 --- a/apps/desktop/src/shared/native/appLifecycle.ts +++ b/apps/desktop/src/shared/native/appLifecycle.ts @@ -1,5 +1,5 @@ import { getVersion, setDockVisibility } from "@tauri-apps/api/app"; -import { isTauri } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart"; import { relaunch } from "@tauri-apps/plugin-process"; import { check, type Update } from "@tauri-apps/plugin-updater"; @@ -46,11 +46,39 @@ export async function writeDockIconVisibility(visible: boolean): Promise { } } -export async function checkForUpdate(): Promise { - return check(); +export type AppUpdate = { + version: string; + install(): Promise; + close(): Promise; +}; + +type PortableUpdateInfo = { + version: string; +}; + +export async function checkForUpdate(): Promise { + if (desktopPlatform() === "windows") { + const update = await invoke("check_portable_update"); + if (!update) return null; + return { + version: update.version, + install: () => invoke("install_portable_update", { expectedVersion: update.version }), + close: async () => {}, + }; + } + + const update: Update | null = await check(); + if (!update) return null; + return { + version: update.version, + install: async () => { + await update.downloadAndInstall(); + await relaunch(); + }, + close: () => update.close(), + }; } -export async function installUpdate(update: Update): Promise { - await update.downloadAndInstall(); - await relaunch(); +export async function installUpdate(update: AppUpdate): Promise { + await update.install(); } diff --git a/apps/desktop/src/shared/store/updateStore.ts b/apps/desktop/src/shared/store/updateStore.ts index a5dfed9..97fe4f1 100644 --- a/apps/desktop/src/shared/store/updateStore.ts +++ b/apps/desktop/src/shared/store/updateStore.ts @@ -1,9 +1,9 @@ import { useSyncExternalStore } from "react"; -import type { Update } from "@tauri-apps/plugin-updater"; import { checkForUpdate, hasNativeAppLifecycle, installUpdate, + type AppUpdate, } from "../native/appLifecycle"; export type UpdateSnapshot = { @@ -17,7 +17,7 @@ let snapshot: UpdateSnapshot = { checking: false, installing: false, }; -let availableUpdate: Update | null = null; +let availableUpdate: AppUpdate | null = null; let pendingCheck: Promise | null = null; const listeners = new Set<() => void>(); @@ -26,7 +26,7 @@ function update(patch: Partial) { listeners.forEach((listener) => listener()); } -async function replaceAvailableUpdate(next: Update | null) { +async function replaceAvailableUpdate(next: AppUpdate | null) { const previous = availableUpdate; availableUpdate = next; update({ availableVersion: next?.version ?? null });