fix(desktop): update Windows executable in place

This commit is contained in:
leookun
2026-09-02 13:02:11 +08:00
parent 919c1d8032
commit ddaa61c827
20 changed files with 850 additions and 37 deletions
@@ -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;
});
}
@@ -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/);
});
+29 -2
View File
@@ -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 }}
Generated
+3
View File
@@ -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]]
+1 -1
View File
@@ -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",
+5
View File
@@ -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"] }
+5 -1
View File
@@ -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")
}
@@ -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",
@@ -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"]
@@ -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"]
+6 -1
View File
@@ -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!());
+7 -1
View File
@@ -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()
}
+269
View File
@@ -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<ExitCode> {
#[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<Option<PortableUpdateInfo>, 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<Option<tauri_plugin_updater::Update>, 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<Vec<u8>, 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<PathBuf> {
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<u8> {
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());
}
}
@@ -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<Option<ReplacementRequest>, String> {
let args = std::env::args_os().collect::<Vec<_>>();
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::<u32>()
.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<PathBuf> {
let args = std::env::args_os().collect::<Vec<_>>();
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());
}
}
@@ -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 }));
}
};
+48 -20
View File
@@ -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
}
]
+2
View File
@@ -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",
+2
View File
@@ -221,6 +221,7 @@
"91aaf184cfc17ffd": "数据概览",
"91af6e57e7453fbe": "添加账号",
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
"92e26b27d5ea8f0e": "检查更新失败:{error}",
"940a168911ade998": "每页条数",
"945fb1c67eca8493": "正在安装插件运行时",
"946b3ffc02f026c0": "确定删除这个模型吗?",
@@ -259,6 +260,7 @@
"a748cc074f78de00": "查看详情",
"a7617f42f898b2bf": "使用完整请求地址",
"a8036485f9227f2c": "拖动排序",
"a80b53f8848e6d27": "安装更新失败:{error}",
"a98585871c5313ff": "显示名称",
"ab9084a640fbb864": "全不选",
"abecab6701177721": "已开启开机启动",
+34 -6
View File
@@ -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<void> {
}
}
export async function checkForUpdate(): Promise<Update | null> {
return check();
export type AppUpdate = {
version: string;
install(): Promise<void>;
close(): Promise<void>;
};
type PortableUpdateInfo = {
version: string;
};
export async function checkForUpdate(): Promise<AppUpdate | null> {
if (desktopPlatform() === "windows") {
const update = await invoke<PortableUpdateInfo | null>("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<void> {
await update.downloadAndInstall();
await relaunch();
export async function installUpdate(update: AppUpdate): Promise<void> {
await update.install();
}
+3 -3
View File
@@ -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<string | null> | null = null;
const listeners = new Set<() => void>();
@@ -26,7 +26,7 @@ function update(patch: Partial<UpdateSnapshot>) {
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 });