mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-05 20:44:07 +08:00
fix(desktop): update Windows executable in place
This commit is contained in:
@@ -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/);
|
||||||
|
});
|
||||||
@@ -158,14 +158,27 @@ jobs:
|
|||||||
mkdir -p legacy-update
|
mkdir -p legacy-update
|
||||||
tar -czf "legacy-update/cursor-byok-${VERSION}-linux-amd64.tar.gz" -C target/release cursor-byok-desktop
|
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'
|
if: matrix.platform == 'windows-x86_64'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.prepare.outputs.version }}
|
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: |
|
run: |
|
||||||
New-Item -ItemType Directory -Force legacy-update | Out-Null
|
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
|
- name: Package legacy macOS updater asset
|
||||||
if: contains(matrix.platform, 'macos')
|
if: contains(matrix.platform, 'macos')
|
||||||
@@ -213,6 +226,20 @@ jobs:
|
|||||||
--output legacy-update/update.json \
|
--output legacy-update/update.json \
|
||||||
--notes "Cursor BYOK v${VERSION}"
|
--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
|
- name: Normalize Tauri updater download URLs
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
Generated
+3
@@ -1188,12 +1188,15 @@ dependencies = [
|
|||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"url",
|
"url",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
"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": "vite build",
|
||||||
"build:demo": "npm run typecheck && npm run typecheck:node && vite build --config vite.demo.config.ts",
|
"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",
|
"check": "npm run typecheck && npm run typecheck:node && npm run build",
|
||||||
|
|||||||
@@ -25,9 +25,14 @@ tauri-plugin-opener = "2"
|
|||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
tempfile = "3"
|
||||||
tokio = { version = "1", features = ["time"] }
|
tokio = { version = "1", features = ["time"] }
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-appender = "0.2"
|
tracing-appender = "0.2"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
url = "2"
|
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"] }
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
fn main() {
|
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))
|
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest))
|
||||||
.expect("failed to build Tauri application")
|
.expect("failed to build Tauri application")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:app:allow-set-dock-visibility",
|
"core:app:allow-set-dock-visibility",
|
||||||
"allow-open-terminal-with-command",
|
"allow-open-terminal-with-command",
|
||||||
|
"allow-check-portable-update",
|
||||||
|
"allow-install-portable-update",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
"autostart:default",
|
"autostart:default",
|
||||||
"process:allow-restart",
|
"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"]
|
||||||
@@ -177,7 +177,11 @@ pub fn run() -> ExitCode {
|
|||||||
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
let started_by_autostart = std::env::args_os().any(|arg| arg == AUTOSTART_ARG);
|
||||||
|
|
||||||
let app = tauri::Builder::default()
|
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, _| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _| {
|
||||||
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
if !args.iter().any(|arg| arg == AUTOSTART_ARG) {
|
||||||
tray::show_main_window(app);
|
tray::show_main_window(app);
|
||||||
@@ -245,6 +249,7 @@ pub fn run() -> ExitCode {
|
|||||||
window.set_focus()?;
|
window.set_focus()?;
|
||||||
}
|
}
|
||||||
tray::create(app)?;
|
tray::create(app)?;
|
||||||
|
crate::update::signal_ready_if_requested()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.build(tauri::generate_context!());
|
.build(tauri::generate_context!());
|
||||||
|
|||||||
@@ -4,5 +4,11 @@ mod frontend;
|
|||||||
mod resource_limits;
|
mod resource_limits;
|
||||||
mod startup;
|
mod startup;
|
||||||
mod tray;
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
const nextVersion = await updateStore.check();
|
||||||
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
|
message(nextVersion ? t("发现新版本 {version}", { version: nextVersion }) : t("当前已是最新版本"));
|
||||||
} catch (cause) {
|
} 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 {
|
try {
|
||||||
await updateStore.install();
|
await updateStore.install();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
message(cause instanceof Error ? cause.message : String(cause));
|
const error = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
message(t("安装更新失败:{error}", { error }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -606,7 +606,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 156,
|
"line": 158,
|
||||||
"column": 27
|
"column": 27
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -916,7 +916,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 152,
|
"line": 154,
|
||||||
"column": 13
|
"column": 13
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1673,7 +1673,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 126,
|
"line": 128,
|
||||||
"column": 17
|
"column": 17
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1725,7 +1725,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 151,
|
"line": 153,
|
||||||
"column": 13
|
"column": 13
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2353,7 +2353,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "shared/api.ts",
|
"file": "shared/api.ts",
|
||||||
"line": 488,
|
"line": 486,
|
||||||
"column": 43
|
"column": 43
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2365,7 +2365,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 149,
|
"line": 151,
|
||||||
"column": 18
|
"column": 18
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2451,12 +2451,12 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 137,
|
"line": 139,
|
||||||
"column": 18
|
"column": 18
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 143,
|
"line": 145,
|
||||||
"column": 16
|
"column": 16
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2680,7 +2680,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "shared/api.ts",
|
"file": "shared/api.ts",
|
||||||
"line": 483,
|
"line": 481,
|
||||||
"column": 43
|
"column": 43
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2795,7 +2795,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 160,
|
"line": 162,
|
||||||
"column": 37
|
"column": 37
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2834,7 +2834,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "shared/api.ts",
|
"file": "shared/api.ts",
|
||||||
"line": 420,
|
"line": 418,
|
||||||
"column": 21
|
"column": 21
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2900,12 +2900,12 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 113,
|
"line": 115,
|
||||||
"column": 18
|
"column": 18
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 119,
|
"line": 121,
|
||||||
"column": 16
|
"column": 16
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3194,6 +3194,20 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"92e26b27d5ea8f0e": {
|
||||||
|
"source": "检查更新失败:{error}",
|
||||||
|
"kind": "template",
|
||||||
|
"placeholders": [
|
||||||
|
"error"
|
||||||
|
],
|
||||||
|
"refs": [
|
||||||
|
{
|
||||||
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
|
"line": 99,
|
||||||
|
"column": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"940a168911ade998": {
|
"940a168911ade998": {
|
||||||
"source": "每页条数",
|
"source": "每页条数",
|
||||||
"kind": "text",
|
"kind": "text",
|
||||||
@@ -3520,7 +3534,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 110,
|
"line": 112,
|
||||||
"column": 29
|
"column": 29
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3532,12 +3546,12 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 125,
|
"line": 127,
|
||||||
"column": 18
|
"column": 18
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 131,
|
"line": 133,
|
||||||
"column": 16
|
"column": 16
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3749,6 +3763,20 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"a80b53f8848e6d27": {
|
||||||
|
"source": "安装更新失败:{error}",
|
||||||
|
"kind": "template",
|
||||||
|
"placeholders": [
|
||||||
|
"error"
|
||||||
|
],
|
||||||
|
"refs": [
|
||||||
|
{
|
||||||
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
|
"line": 108,
|
||||||
|
"column": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"a98585871c5313ff": {
|
"a98585871c5313ff": {
|
||||||
"source": "显示名称",
|
"source": "显示名称",
|
||||||
"kind": "text",
|
"kind": "text",
|
||||||
@@ -3814,7 +3842,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 156,
|
"line": 158,
|
||||||
"column": 39
|
"column": 39
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -3854,7 +3882,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 138,
|
"line": 140,
|
||||||
"column": 17
|
"column": 17
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -4577,7 +4605,7 @@
|
|||||||
"refs": [
|
"refs": [
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 114,
|
"line": 116,
|
||||||
"column": 17
|
"column": 17
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -5522,7 +5550,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
"file": "features/settings/AppLifecycleSettingsCard.tsx",
|
||||||
"line": 160,
|
"line": 162,
|
||||||
"column": 25
|
"column": 25
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
"91aaf184cfc17ffd": "Overview",
|
"91aaf184cfc17ffd": "Overview",
|
||||||
"91af6e57e7453fbe": "Add account",
|
"91af6e57e7453fbe": "Add account",
|
||||||
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
|
"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",
|
"940a168911ade998": "Items per page",
|
||||||
"945fb1c67eca8493": "Installing the plugin runtime",
|
"945fb1c67eca8493": "Installing the plugin runtime",
|
||||||
"946b3ffc02f026c0": "Delete this model?",
|
"946b3ffc02f026c0": "Delete this model?",
|
||||||
@@ -259,6 +260,7 @@
|
|||||||
"a748cc074f78de00": "View details",
|
"a748cc074f78de00": "View details",
|
||||||
"a7617f42f898b2bf": "Use complete request URL",
|
"a7617f42f898b2bf": "Use complete request URL",
|
||||||
"a8036485f9227f2c": "Drag to reorder",
|
"a8036485f9227f2c": "Drag to reorder",
|
||||||
|
"a80b53f8848e6d27": "Failed to install update: {error}",
|
||||||
"a98585871c5313ff": "Display name",
|
"a98585871c5313ff": "Display name",
|
||||||
"ab9084a640fbb864": "Deselect all",
|
"ab9084a640fbb864": "Deselect all",
|
||||||
"abecab6701177721": "Launch at login enabled",
|
"abecab6701177721": "Launch at login enabled",
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
"91aaf184cfc17ffd": "数据概览",
|
"91aaf184cfc17ffd": "数据概览",
|
||||||
"91af6e57e7453fbe": "添加账号",
|
"91af6e57e7453fbe": "添加账号",
|
||||||
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
"92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。",
|
||||||
|
"92e26b27d5ea8f0e": "检查更新失败:{error}",
|
||||||
"940a168911ade998": "每页条数",
|
"940a168911ade998": "每页条数",
|
||||||
"945fb1c67eca8493": "正在安装插件运行时",
|
"945fb1c67eca8493": "正在安装插件运行时",
|
||||||
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
"946b3ffc02f026c0": "确定删除这个模型吗?",
|
||||||
@@ -259,6 +260,7 @@
|
|||||||
"a748cc074f78de00": "查看详情",
|
"a748cc074f78de00": "查看详情",
|
||||||
"a7617f42f898b2bf": "使用完整请求地址",
|
"a7617f42f898b2bf": "使用完整请求地址",
|
||||||
"a8036485f9227f2c": "拖动排序",
|
"a8036485f9227f2c": "拖动排序",
|
||||||
|
"a80b53f8848e6d27": "安装更新失败:{error}",
|
||||||
"a98585871c5313ff": "显示名称",
|
"a98585871c5313ff": "显示名称",
|
||||||
"ab9084a640fbb864": "全不选",
|
"ab9084a640fbb864": "全不选",
|
||||||
"abecab6701177721": "已开启开机启动",
|
"abecab6701177721": "已开启开机启动",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getVersion, setDockVisibility } from "@tauri-apps/api/app";
|
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 { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
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> {
|
export type AppUpdate = {
|
||||||
return check();
|
version: string;
|
||||||
}
|
install(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function installUpdate(update: Update): 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 update.downloadAndInstall();
|
||||||
await relaunch();
|
await relaunch();
|
||||||
|
},
|
||||||
|
close: () => update.close(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installUpdate(update: AppUpdate): Promise<void> {
|
||||||
|
await update.install();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import type { Update } from "@tauri-apps/plugin-updater";
|
|
||||||
import {
|
import {
|
||||||
checkForUpdate,
|
checkForUpdate,
|
||||||
hasNativeAppLifecycle,
|
hasNativeAppLifecycle,
|
||||||
installUpdate,
|
installUpdate,
|
||||||
|
type AppUpdate,
|
||||||
} from "../native/appLifecycle";
|
} from "../native/appLifecycle";
|
||||||
|
|
||||||
export type UpdateSnapshot = {
|
export type UpdateSnapshot = {
|
||||||
@@ -17,7 +17,7 @@ let snapshot: UpdateSnapshot = {
|
|||||||
checking: false,
|
checking: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
};
|
};
|
||||||
let availableUpdate: Update | null = null;
|
let availableUpdate: AppUpdate | null = null;
|
||||||
let pendingCheck: Promise<string | null> | null = null;
|
let pendingCheck: Promise<string | null> | null = null;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ function update(patch: Partial<UpdateSnapshot>) {
|
|||||||
listeners.forEach((listener) => listener());
|
listeners.forEach((listener) => listener());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceAvailableUpdate(next: Update | null) {
|
async function replaceAvailableUpdate(next: AppUpdate | null) {
|
||||||
const previous = availableUpdate;
|
const previous = availableUpdate;
|
||||||
availableUpdate = next;
|
availableUpdate = next;
|
||||||
update({ availableVersion: next?.version ?? null });
|
update({ availableVersion: next?.version ?? null });
|
||||||
|
|||||||
Reference in New Issue
Block a user