diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9913b05..eea2f2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,7 @@ jobs: NODE echo "stem=EasyCLIProxyAPI-${version}-${{ matrix.display_os }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT" + echo "version=${version#v}" >> "$GITHUB_OUTPUT" - name: Install Linux dependencies if: matrix.os == 'linux' run: | @@ -189,6 +190,23 @@ jobs: throw new Error(`cpa-core must contain exactly one archive; found: ${entries.map((entry) => entry.name).join(', ')}`); } NODE + - name: Prepare Windows portable updater payload + if: matrix.os == 'windows' + shell: pwsh + run: | + $updateDir = "release/update-${{ matrix.arch }}" + New-Item -ItemType Directory -Force -Path $updateDir | Out-Null + Copy-Item -LiteralPath "release/${{ steps.package.outputs.stem }}/EasyCLIProxyAPI.exe" -Destination $updateDir + Copy-Item -LiteralPath "release/${{ steps.package.outputs.stem }}/portable-app.json" -Destination $updateDir + $marker = Get-Content -Raw -LiteralPath "$updateDir/portable-app.json" | ConvertFrom-Json + if ($marker.schemaVersion -ne 1 -or + $marker.application -ne "EasyCLIProxyAPI" -or + $marker.version -ne "${{ steps.package.outputs.version }}" -or + $marker.platform -ne "windows" -or + $marker.arch -ne "${{ matrix.arch }}" -or + $marker.autoUpdate -ne $true) { + throw "portable-app.json does not match the Windows updater target" + } - name: Build signed macOS DMG if: matrix.os == 'darwin' shell: bash @@ -323,13 +341,35 @@ jobs: - name: Archive Windows release if: matrix.os == 'windows' shell: pwsh - run: Compress-Archive -Path "release/${{ steps.package.outputs.stem }}" -DestinationPath "${{ steps.package.outputs.stem }}.zip" + run: | + Compress-Archive -Path "release/${{ steps.package.outputs.stem }}" -DestinationPath "${{ steps.package.outputs.stem }}.zip" + $updateArchive = "EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip" + Compress-Archive -Path "release/update-${{ matrix.arch }}/*" -DestinationPath $updateArchive + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $updateArchive)) + try { + $entries = @($zip.Entries | ForEach-Object { $_.FullName.Replace('\', '/') }) + if ($entries.Count -ne 2 -or + $entries -notcontains "EasyCLIProxyAPI.exe" -or + $entries -notcontains "portable-app.json") { + throw "Portable updater ZIP contains unexpected entries: $($entries -join ', ')" + } + } finally { + $zip.Dispose() + } - uses: actions/upload-artifact@v7 with: name: ${{ steps.package.outputs.stem }} path: ${{ steps.package.outputs.stem }}.${{ matrix.suffix }} if-no-files-found: error retention-days: 14 + - uses: actions/upload-artifact@v7 + if: matrix.os == 'windows' + with: + name: portable-updater-windows-${{ matrix.arch }} + path: EasyCLIProxyAPI-update-v${{ steps.package.outputs.version }}-Windows-${{ matrix.arch }}.zip + if-no-files-found: error + retention-days: 14 publish: name: Publish GitHub release @@ -341,10 +381,23 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} - uses: actions/download-artifact@v8 with: path: artifacts merge-multiple: true + - name: Generate Windows portable update manifest + shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name || (github.ref_type == 'tag' && github.ref_name) || inputs.release_tag }} + run: >- + node scripts/generate-portable-update-manifest.mjs + --directory artifacts + --output artifacts/portable-update-windows.json + --repository "${{ github.repository }}" + --tag "$RELEASE_TAG" - name: Publish release shell: bash env: diff --git a/scripts/generate-portable-update-manifest.d.mts b/scripts/generate-portable-update-manifest.d.mts new file mode 100644 index 0000000..88b6dce --- /dev/null +++ b/scripts/generate-portable-update-manifest.d.mts @@ -0,0 +1,21 @@ +export type PortableUpdateManifestAsset = { + url: string; + sha256: string; + sizeBytes: number; +}; + +export type PortableUpdateManifest = { + schemaVersion: number; + version: string; + publishedAt: string; + releaseUrl: string; + assets: Record; +}; + +export function generatePortableUpdateManifest(options: { + directory?: string; + output?: string; + repository?: string; + tag?: string; + publishedAt?: string; +}): Promise; diff --git a/scripts/generate-portable-update-manifest.mjs b/scripts/generate-portable-update-manifest.mjs new file mode 100644 index 0000000..82cfab9 --- /dev/null +++ b/scripts/generate-portable-update-manifest.mjs @@ -0,0 +1,77 @@ +import { createHash } from 'node:crypto'; +import { readFile, stat, writeFile } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export async function generatePortableUpdateManifest({ + directory, + output, + repository, + tag: rawTag, + publishedAt = new Date().toISOString(), +}) { + const resolvedDirectory = resolve(directory ?? 'artifacts'); + const resolvedOutput = resolve(output ?? join(resolvedDirectory, 'portable-update-windows.json')); + const resolvedRepository = repository ?? 'router-for-me/EasyCLIProxyAPI'; + const normalizedRawTag = String(rawTag ?? '').trim(); + const tag = normalizedRawTag.startsWith('v') ? normalizedRawTag : `v${normalizedRawTag}`; + const version = tag.slice(1); + const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + + if (!semverPattern.test(version)) { + throw new Error(`Invalid release tag: ${normalizedRawTag}`); + } + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedRepository)) { + throw new Error(`Invalid GitHub repository: ${resolvedRepository}`); + } + if (Number.isNaN(Date.parse(publishedAt))) { + throw new Error(`Invalid publishedAt: ${publishedAt}`); + } + + const assets = {}; + for (const arch of ['amd64', 'aarch64']) { + const filename = `EasyCLIProxyAPI-update-${tag}-Windows-${arch}.zip`; + const path = join(resolvedDirectory, filename); + const [contents, metadata] = await Promise.all([readFile(path), stat(path)]); + if (!metadata.isFile() || metadata.size === 0) { + throw new Error(`Portable updater asset is empty or not a file: ${filename}`); + } + assets[`windows-${arch}`] = { + url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`, + sha256: createHash('sha256').update(contents).digest('hex'), + sizeBytes: metadata.size, + }; + } + + const manifest = { + schemaVersion: 1, + version, + publishedAt, + releaseUrl: `https://github.com/${resolvedRepository}/releases/tag/${tag}`, + assets, + }; + + await writeFile(resolvedOutput, `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; +} + +async function main() { + const args = new Map(); + for (let index = 2; index < process.argv.length; index += 2) { + args.set(process.argv[index], process.argv[index + 1]); + } + const directory = resolve(args.get('--directory') ?? 'artifacts'); + const output = resolve(args.get('--output') ?? join(directory, 'portable-update-windows.json')); + const manifest = await generatePortableUpdateManifest({ + directory, + output, + repository: args.get('--repository'), + tag: args.get('--tag'), + }); + console.log(`Generated ${basename(output)} for v${manifest.version}`); +} + +const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; +if (import.meta.url === entryPoint) { + await main(); +} diff --git a/scripts/prepare-portable.mjs b/scripts/prepare-portable.mjs index 8b0b96f..1615c06 100644 --- a/scripts/prepare-portable.mjs +++ b/scripts/prepare-portable.mjs @@ -21,6 +21,12 @@ if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`); const rawVersion = (await readFile(join(root, 'core-version.txt'), 'utf8')).trim(); if (!/^v?\d+(?:\.\d+)+$/.test(rawVersion)) throw new Error(`Invalid core-version.txt: ${rawVersion}`); const version = rawVersion.replace(/^v/i, ''); +const packageMetadata = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); +const appVersion = String(packageMetadata.version ?? '').trim(); +const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +if (!semverPattern.test(appVersion)) { + throw new Error(`Invalid package.json version: ${appVersion}`); +} const extension = targetOS === 'windows' ? 'zip' : 'tar.gz'; const assetName = `CLIProxyAPI_${version}_${targetOS}_${targetArch}.${extension}`; const sourceDir = join(root, 'cpa-core'); @@ -59,6 +65,14 @@ await rm(legacyOutputBinary, { force: true }); await copyFile(binary, outputBinary); if (targetOS !== 'windows') await chmod(outputBinary, 0o755); await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt')); +await writeFile(join(output, 'portable-app.json'), `${JSON.stringify({ + schemaVersion: 1, + application: 'EasyCLIProxyAPI', + version: appVersion, + platform: targetOS, + arch: targetArch, + autoUpdate: targetOS === 'windows', +}, null, 2)}\n`); const coreOutput = join(output, 'cpa-core'); await rm(coreOutput, { recursive: true, force: true }); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8bd9e83..46373cd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -35,7 +35,7 @@ use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, }; -use tauri::{Emitter, Manager}; +use tauri::{Emitter, LogicalSize, Manager}; use tauri_plugin_opener::OpenerExt; use tokio_util::sync::CancellationToken; use zip::ZipArchive; @@ -44,8 +44,13 @@ const RELEASE_PAGE_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/rel const RELEASE_ATOM_URL: &str = "https://github.com/router-for-me/CLIProxyAPI/releases.atom"; const RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/router-for-me/CLIProxyAPI/releases/download/"; -const APP_RELEASE_PAGE_URL: &str = - "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest"; +const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json"; +const APP_RELEASE_DOWNLOAD_PREFIX: &str = + "https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/"; +const APP_UPDATE_PROGRESS_EVENT: &str = "app-update-progress"; +const PORTABLE_APP_MANIFEST_FILE: &str = "portable-app.json"; +#[cfg(windows)] +const PORTABLE_APP_BINARY: &str = "EasyCLIProxyAPI.exe"; const CORE_INSTALL_PROGRESS_EVENT: &str = "core-install-progress"; const CORE_STATUS_EVENT: &str = "core-status-changed"; #[cfg(target_os = "windows")] @@ -57,9 +62,12 @@ const CORE_VERSION_FILE: &str = "core-version.txt"; const CORE_CHECKSUMS_FILE: &str = "checksums.txt"; const GUI_CONFIG_FILE: &str = "config.toml"; const LEGACY_GUI_CONFIG_FILE: &str = "cpa-gui.yaml"; +const MIN_MAIN_WINDOW_WIDTH: u32 = 640; +const MIN_MAIN_WINDOW_HEIGHT: u32 = 600; +const MAX_SAVED_WINDOW_DIMENSION: u32 = 16_384; const OAUTH_DIR_NAME: &str = "oauth"; const DEFAULT_API_KEY: &str = "123456"; -const DEFAULT_API_KEY_REMARK: &str = "内置密钥"; +const DEFAULT_API_KEY_INITIAL_REMARK: &str = "默认密钥"; const DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; const CODEX_MODEL_CATALOG_FILE: &str = "cpa-gui-model-catalog.json"; @@ -159,6 +167,18 @@ struct CoreDownloadInner { task: CoreInstallTask, } +#[derive(Default)] +struct AppUpdateState { + inner: Mutex, +} + +#[derive(Default)] +struct AppUpdateInner { + task: AppUpdateTask, + token: Option, + pending: Option, +} + #[derive(Default)] struct CoreProcessState { child: Mutex>, @@ -170,6 +190,16 @@ struct GuiConfigState { inner: Mutex, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SavedWindowSize { + width: u32, + height: u32, +} + +struct MainWindowSizeState { + inner: Mutex>, +} + #[derive(Default)] struct AgentConfigStatusCache { refresh_lock: Mutex<()>, @@ -243,13 +273,97 @@ struct CoreLatest { asset_name: String, } -#[derive(Serialize)] +#[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct AppUpdateInfo { current_version: String, latest_version: String, update_available: bool, release_url: String, + auto_update_supported: bool, + download_size_bytes: Option, + unsupported_reason: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PortableUpdateManifest { + schema_version: u32, + version: String, + published_at: String, + release_url: String, + assets: std::collections::HashMap, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PortableUpdateAsset { + url: String, + sha256: String, + size_bytes: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PortableAppManifest { + schema_version: u32, + application: String, + version: String, + platform: String, + arch: String, + #[serde(default)] + auto_update: bool, +} + +#[derive(Clone)] +struct PendingAppUpdate { + version: String, + asset: PortableUpdateAsset, + arch: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AppUpdateTask { + running: bool, + cancellable: bool, + phase: String, + target_version: Option, + downloaded_bytes: u64, + total_bytes: Option, + percent: Option, + message: Option, +} + +impl Default for AppUpdateTask { + fn default() -> Self { + Self { + running: false, + cancellable: false, + phase: "idle".to_string(), + target_version: None, + downloaded_bytes: 0, + total_bytes: None, + percent: None, + message: None, + } + } +} + +#[cfg(windows)] +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PortableUpdateDescriptor { + parent_pid: u32, + current_exe: PathBuf, + staged_exe: PathBuf, + current_manifest: PathBuf, + staged_manifest: PathBuf, + backup_exe: PathBuf, + backup_manifest: PathBuf, + ack_path: PathBuf, + work_dir: PathBuf, + target_version: String, } #[derive(Clone, Serialize)] @@ -289,6 +403,8 @@ struct GuiConfigFile { port: u16, allow_lan: bool, run_on_startup: bool, + window_width: Option, + window_height: Option, auth_dir: String, #[serde(deserialize_with = "deserialize_gui_api_keys")] api_keys: Vec, @@ -342,12 +458,14 @@ impl Default for GuiConfigFile { port: 8317, allow_lan: false, run_on_startup: false, + window_width: None, + window_height: None, auth_dir: env::current_exe() .ok() .and_then(|path| path.parent().map(|parent| parent.join(OAUTH_DIR_NAME))) .map(|path| path_to_string(&path)) .unwrap_or_else(|| OAUTH_DIR_NAME.to_string()), - api_keys: vec![built_in_api_key_entry()], + api_keys: vec![default_api_key_entry()], // Keep plaintext here for management API auth. Core hashes the // value written into config.yaml on startup. management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(), @@ -616,7 +734,6 @@ struct CoreConfigSettings { struct CoreApiKeyView { api_key: String, remark: String, - built_in: bool, } #[derive(Clone, Serialize)] @@ -767,6 +884,81 @@ impl CoreDownloadState { } } +impl AppUpdateState { + fn snapshot(&self) -> AppUpdateTask { + self.inner + .lock() + .map(|inner| inner.task.clone()) + .unwrap_or_default() + } + + fn set_pending(&self, pending: Option, task: AppUpdateTask) { + if let Ok(mut inner) = self.inner.lock() { + inner.pending = pending; + if !inner.task.running { + inner.task = task; + } + } + } + + fn start(&self, token: CancellationToken) -> Result { + let mut inner = self + .inner + .lock() + .map_err(|_| "应用更新状态锁已损坏".to_string())?; + if inner.task.running { + return Err("已有应用更新任务正在运行".to_string()); + } + let pending = inner + .pending + .clone() + .ok_or_else(|| "没有可安装的应用更新,请先检查更新".to_string())?; + inner.token = Some(token); + inner.task = AppUpdateTask { + running: true, + cancellable: true, + phase: "downloading".to_string(), + target_version: Some(pending.version.clone()), + downloaded_bytes: 0, + total_bytes: Some(pending.asset.size_bytes), + percent: Some(0.0), + message: None, + }; + Ok(pending) + } + + fn update_task(&self, update: F) -> AppUpdateTask + where + F: FnOnce(&mut AppUpdateTask), + { + let Ok(mut inner) = self.inner.lock() else { + return AppUpdateTask::default(); + }; + update(&mut inner.task); + inner.task.clone() + } + + fn finish(&self, phase: &str, message: Option) -> AppUpdateTask { + let Ok(mut inner) = self.inner.lock() else { + return AppUpdateTask::default(); + }; + inner.task.running = false; + inner.task.cancellable = false; + inner.task.phase = phase.to_string(); + inner.task.message = message; + inner.token = None; + inner.task.clone() + } + + fn cancel(&self) { + if let Ok(inner) = self.inner.lock() { + if let Some(token) = &inner.token { + token.cancel(); + } + } + } +} + impl CoreProcessState { fn managed_pid(&self) -> Option { let Ok(mut child) = self.child.lock() else { @@ -830,6 +1022,30 @@ impl CoreProcessState { } } +impl MainWindowSizeState { + fn new(size: Option) -> Self { + Self { + inner: Mutex::new(size), + } + } + + fn snapshot(&self) -> Result, String> { + self.inner + .lock() + .map(|size| *size) + .map_err(|_| "主窗口尺寸状态锁已损坏".to_string()) + } + + fn replace(&self, size: SavedWindowSize) -> Result<(), String> { + let mut current = self + .inner + .lock() + .map_err(|_| "主窗口尺寸状态锁已损坏".to_string())?; + *current = Some(size); + Ok(()) + } +} + impl GuiConfigState { fn new(config: GuiConfigFile) -> Self { Self { @@ -866,6 +1082,14 @@ impl GuiConfigState { }) } + fn set_window_size(&self, size: SavedWindowSize) -> Result { + self.update(|config| { + config.window_width = Some(size.width); + config.window_height = Some(size.height); + Ok(()) + }) + } + fn set_management_secret_key(&self, secret_key: String) -> Result { self.update(|config| { config.management_secret_key = secret_key; @@ -941,7 +1165,6 @@ impl From<&GuiConfigFile> for CoreConfigView { .map(|entry| CoreApiKeyView { api_key: entry.key.clone(), remark: entry.remark.clone(), - built_in: entry.key == DEFAULT_API_KEY, }) .collect(), management_secret_configured: !config.management_secret_key.is_empty(), @@ -1751,10 +1974,10 @@ fn hermes_agent_config_path(home: &Path) -> PathBuf { } #[cfg(target_os = "windows")] { - return env::var_os("LOCALAPPDATA") + env::var_os("LOCALAPPDATA") .map(PathBuf::from) .unwrap_or_else(|| home.join("AppData/Local")) - .join("hermes/config.yaml"); + .join("hermes/config.yaml") } #[cfg(not(target_os = "windows"))] home.join(".hermes/config.yaml") @@ -2008,7 +2231,7 @@ fn find_codex_app_target(home: &Path) -> Option { #[cfg(target_os = "windows")] { - return find_windows_codex_app_target(home); + find_windows_codex_app_target(home) } #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -2411,12 +2634,12 @@ fn find_claude_desktop_executable(home: &Path) -> Option { let local = env::var_os("LOCALAPPDATA") .map(PathBuf::from) .unwrap_or_else(|| home.join("AppData/Local")); - return [ + [ local.join("Programs/Claude/Claude.exe"), local.join("Claude/Claude.exe"), ] .into_iter() - .find(|path| path.is_file()); + .find(|path| path.is_file()) } #[cfg(target_os = "linux")] { @@ -4528,17 +4751,13 @@ fn add_core_api_key( validate_core_api_key(&api_key)?; validate_api_key_remark(&remark)?; let mut settings = current_core_config_settings(gui_config_state.inner())?; - if api_key == DEFAULT_API_KEY - || settings - .api_keys - .iter() - .any(|existing| existing == &api_key) + if settings + .api_keys + .iter() + .any(|existing| existing == &api_key) { return Err("该鉴权密钥已经存在".to_string()); } - if !settings.api_keys.iter().any(|key| key == DEFAULT_API_KEY) { - settings.api_keys.insert(0, DEFAULT_API_KEY.to_string()); - } settings.api_keys.push(api_key); patch_core_api_keys(&settings.api_keys)?; let added_api_key = settings.api_keys.last().map(|key| GuiApiKeyEntry { @@ -4549,6 +4768,64 @@ fn add_core_api_key( Ok(CoreConfigView::from(&config)) } +fn replace_core_api_key_value( + api_keys: &mut [String], + original_api_key: &str, + replacement_api_key: String, +) -> Result<(), String> { + let index = api_keys + .iter() + .position(|existing| existing == original_api_key) + .ok_or_else(|| "要编辑的鉴权密钥不存在,请刷新后重试".to_string())?; + if replacement_api_key != original_api_key + && api_keys + .iter() + .any(|existing| existing == &replacement_api_key) + { + return Err("该鉴权密钥已经存在".to_string()); + } + api_keys[index] = replacement_api_key; + Ok(()) +} + +fn remove_core_api_key_value(api_keys: &mut Vec, api_key: &str) -> Result<(), String> { + let index = api_keys + .iter() + .position(|existing| existing == api_key) + .ok_or_else(|| "要删除的鉴权密钥不存在,请刷新后重试".to_string())?; + api_keys.remove(index); + Ok(()) +} + +#[tauri::command] +fn update_core_api_key( + gui_config_state: tauri::State<'_, GuiConfigState>, + original_api_key: String, + api_key: String, + remark: String, +) -> Result { + let original_api_key = original_api_key.trim(); + let api_key = api_key.trim().to_string(); + let remark = remark.trim().to_string(); + if original_api_key.is_empty() { + return Err("要编辑的鉴权密钥不能为空".to_string()); + } + validate_core_api_key(&api_key)?; + validate_api_key_remark(&remark)?; + + let mut settings = current_core_config_settings(gui_config_state.inner())?; + replace_core_api_key_value(&mut settings.api_keys, original_api_key, api_key.clone())?; + patch_core_api_keys(&settings.api_keys)?; + let config = gui_config_state.sync_core_settings_with_api_key( + &settings, + Some(GuiApiKeyEntry { + key: api_key, + remark, + }), + )?; + Ok(CoreConfigView::from(&config)) +} + #[tauri::command] fn delete_core_api_key( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -4558,16 +4835,8 @@ fn delete_core_api_key( if api_key.is_empty() { return Err("要删除的鉴权密钥不能为空".to_string()); } - if api_key == DEFAULT_API_KEY { - return Err("内置密钥不能删除".to_string()); - } let mut settings = current_core_config_settings(gui_config_state.inner())?; - let index = settings - .api_keys - .iter() - .position(|existing| existing == api_key) - .ok_or_else(|| "要删除的鉴权密钥不存在,请刷新后重试".to_string())?; - settings.api_keys.remove(index); + remove_core_api_key_value(&mut settings.api_keys, api_key)?; patch_core_api_keys(&settings.api_keys)?; let config = gui_config_state.sync_core_settings(&settings)?; Ok(CoreConfigView::from(&config)) @@ -4881,44 +5150,541 @@ fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> { } #[tauri::command] -async fn check_app_update() -> Result { +async fn check_app_update( + state: tauri::State<'_, AppUpdateState>, +) -> Result { let client = reqwest::Client::builder() + .redirect(github_https_redirect_policy()) .connect_timeout(Duration::from_secs(8)) .read_timeout(Duration::from_secs(15)) .timeout(Duration::from_secs(20)) .build() .map_err(|error| format!("创建版本检查客户端失败: {error}"))?; - let response = client - .get(APP_RELEASE_PAGE_URL) - .header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml") + let manifest = client + .get(APP_UPDATE_MANIFEST_URL) + .header(reqwest::header::ACCEPT, "application/json") .header(reqwest::header::USER_AGENT, APP_USER_AGENT) .send() .await - .map_err(|error| format!("检查软件更新失败: {error}"))?; - let status = response.status(); - let final_url = response.url().clone(); - if !status.is_success() { - let body = response - .text() - .await - .map_err(|error| format!("读取软件更新响应失败: {error}"))?; - return Err(format_github_error(status.as_u16(), &body)); - } + .map_err(|error| format!("检查软件更新失败: {error}"))? + .error_for_status() + .map_err(|error| format!("读取软件更新清单失败: {error}"))? + .json::() + .await + .map_err(|error| format!("解析软件更新清单失败: {error}"))?; - let latest_version = release_tag_from_url(&final_url) - .map(|version| normalize_version(&version)) - .ok_or_else(|| "GitHub latest 页面没有返回正式版本标签".to_string())?; + validate_portable_update_manifest(&manifest)?; + let latest_version = normalize_version(&manifest.version); let current_version = normalize_version(env!("CARGO_PKG_VERSION")); let update_available = is_app_update_available(¤t_version, &latest_version)?; + let target = portable_update_target(); + let asset = target + .and_then(|(key, _)| manifest.assets.get(key)) + .cloned(); + let portable_support = target + .map(|(_, arch)| validate_local_portable_app_manifest(arch)) + .transpose()?; + let auto_update_supported = cfg!(windows) && portable_support == Some(true) && asset.is_some(); + let unsupported_reason = if auto_update_supported { + None + } else if !cfg!(windows) { + Some("应用内自动升级当前仅支持 Windows 便携版".to_string()) + } else if portable_support != Some(true) { + Some("当前程序不是支持自动升级的便携版,请手动下载首个支持版本".to_string()) + } else { + Some("更新清单不包含当前 Windows 架构".to_string()) + }; + + let pending = if update_available && auto_update_supported { + let (_, arch) = target.expect("portable target checked above"); + Some(PendingAppUpdate { + version: latest_version.clone(), + asset: asset.clone().expect("portable asset checked above"), + arch: arch.to_string(), + }) + } else { + None + }; + state.set_pending( + pending, + AppUpdateTask { + phase: if update_available { + "available".to_string() + } else { + "idle".to_string() + }, + target_version: update_available.then(|| latest_version.clone()), + total_bytes: asset.as_ref().map(|value| value.size_bytes), + ..AppUpdateTask::default() + }, + ); Ok(AppUpdateInfo { current_version, latest_version, update_available, - release_url: APP_RELEASE_PAGE_URL.to_string(), + release_url: manifest.release_url, + auto_update_supported, + download_size_bytes: asset.map(|value| value.size_bytes), + unsupported_reason, }) } +fn github_https_redirect_policy() -> reqwest::redirect::Policy { + reqwest::redirect::Policy::custom(|attempt| { + let url = attempt.url(); + let trusted_host = matches!( + url.host_str(), + Some( + "github.com" + | "objects.githubusercontent.com" + | "release-assets.githubusercontent.com" + ) + ); + if url.scheme() == "https" + && url.port().is_none() + && url.username().is_empty() + && url.password().is_none() + && trusted_host + { + attempt.follow() + } else { + attempt.stop() + } + }) +} + +fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Result<(), String> { + if manifest.schema_version != 1 { + return Err(format!( + "不支持的软件更新清单版本: {}", + manifest.schema_version + )); + } + semver::Version::parse(manifest.version.trim().trim_start_matches('v')) + .map_err(|error| format!("软件更新版本无效: {error}"))?; + chrono::DateTime::parse_from_rfc3339(manifest.published_at.trim()) + .map_err(|error| format!("软件更新发布时间无效: {error}"))?; + let release_url = reqwest::Url::parse(&manifest.release_url) + .map_err(|_| "软件更新发布地址无效".to_string())?; + if release_url.scheme() != "https" + || release_url.host_str() != Some("github.com") + || release_url.port().is_some() + || !release_url.username().is_empty() + || release_url.password().is_some() + || release_url.query().is_some() + || release_url.fragment().is_some() + || !release_url + .path() + .starts_with("/router-for-me/EasyCLIProxyAPI/releases/tag/v") + { + return Err("软件更新发布地址不受信任".to_string()); + } + if manifest.assets.len() != 2 { + return Err("软件更新清单必须包含两个 Windows 架构".to_string()); + } + for (key, arch) in [("windows-amd64", "amd64"), ("windows-aarch64", "aarch64")] { + let asset = manifest + .assets + .get(key) + .ok_or_else(|| format!("软件更新清单缺少 {key}"))?; + validate_portable_update_asset(asset)?; + let expected_name = format!( + "EasyCLIProxyAPI-update-v{}-Windows-{arch}.zip", + manifest.version.trim().trim_start_matches('v') + ); + let expected_url = format!( + "{APP_RELEASE_DOWNLOAD_PREFIX}v{}/{}", + manifest.version.trim().trim_start_matches('v'), + expected_name + ); + if asset.url != expected_url { + return Err(format!("软件更新资产名称与 {key} 不匹配")); + } + } + Ok(()) +} + +fn validate_portable_update_asset(asset: &PortableUpdateAsset) -> Result<(), String> { + let url = reqwest::Url::parse(&asset.url).map_err(|_| "软件更新下载地址无效".to_string())?; + if url.scheme() != "https" + || url.host_str() != Some("github.com") + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !asset.url.starts_with(APP_RELEASE_DOWNLOAD_PREFIX) + { + return Err("软件更新下载地址不受信任".to_string()); + } + if asset.size_bytes == 0 || asset.size_bytes > 512 * 1024 * 1024 { + return Err("软件更新包大小无效".to_string()); + } + let digest = asset.sha256.trim().to_ascii_lowercase(); + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("软件更新 SHA-256 无效".to_string()); + } + Ok(()) +} + +fn portable_update_target() -> Option<(&'static str, &'static str)> { + if !cfg!(windows) { + return None; + } + match env::consts::ARCH { + "x86_64" => Some(("windows-amd64", "amd64")), + "aarch64" => Some(("windows-aarch64", "aarch64")), + _ => None, + } +} + +fn validate_local_portable_app_manifest(expected_arch: &str) -> Result { + let path = executable_dir()?.join(PORTABLE_APP_MANIFEST_FILE); + if !path.is_file() { + return Ok(false); + } + let contents = + fs::read_to_string(&path).map_err(|error| format!("读取便携版标识失败: {error}"))?; + let manifest = serde_json::from_str::(&contents) + .map_err(|error| format!("解析便携版标识失败: {error}"))?; + Ok(manifest.schema_version == 1 + && manifest.application == "EasyCLIProxyAPI" + && manifest.platform == "windows" + && manifest.arch == expected_arch + && manifest.auto_update + && normalize_version(&manifest.version) == normalize_version(env!("CARGO_PKG_VERSION"))) +} + +#[tauri::command] +fn get_app_update_task(state: tauri::State<'_, AppUpdateState>) -> AppUpdateTask { + state.snapshot() +} + +#[tauri::command] +fn cancel_app_update(state: tauri::State<'_, AppUpdateState>) -> Result<(), String> { + let task = state.snapshot(); + if !task.running || !task.cancellable { + return Err("当前应用更新阶段无法取消".to_string()); + } + state.cancel(); + Ok(()) +} + +#[tauri::command] +async fn start_app_update( + app: tauri::AppHandle, + state: tauri::State<'_, AppUpdateState>, +) -> Result<(), String> { + if !cfg!(windows) { + return Err("应用内自动升级当前仅支持 Windows 便携版".to_string()); + } + let token = CancellationToken::new(); + let pending = state.start(token.clone())?; + let task = state.snapshot(); + let _ = app.emit(APP_UPDATE_PROGRESS_EVENT, task); + let update_app = app.clone(); + tauri::async_runtime::spawn(async move { + let outcome = download_and_stage_portable_app_update(&update_app, &pending, &token).await; + if let Err(error) = outcome { + let state = update_app.state::(); + let cancelled = token.is_cancelled(); + let task = state.finish( + if cancelled { "cancelled" } else { "failed" }, + Some(if cancelled { + "应用更新下载已取消".to_string() + } else { + error + }), + ); + let _ = update_app.emit(APP_UPDATE_PROGRESS_EVENT, task); + } + }); + Ok(()) +} + +async fn download_and_stage_portable_app_update( + app: &tauri::AppHandle, + pending: &PendingAppUpdate, + token: &CancellationToken, +) -> Result<(), String> { + #[cfg(not(windows))] + { + let _ = (app, pending, token); + return Err("应用内自动升级当前仅支持 Windows 便携版".to_string()); + } + + #[cfg(windows)] + { + validate_portable_update_asset(&pending.asset)?; + let work_dir = env::temp_dir().join(format!( + "EasyCLIProxyAPI-update-{}-{}-{}", + pending.version, + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + fs::create_dir_all(&work_dir) + .map_err(|error| format!("创建应用更新临时目录失败: {error}"))?; + let archive_path = work_dir.join("update.zip"); + let result = async { + download_portable_update_archive(app, pending, token, &archive_path).await?; + if token.is_cancelled() { + return Err("应用更新下载已取消".to_string()); + } + + update_app_task(app, |task| { + task.cancellable = false; + task.phase = "verifying".to_string(); + task.message = Some("正在校验应用更新包".to_string()); + }); + let actual_sha256 = sha256_file(&archive_path)?; + if actual_sha256 != pending.asset.sha256.trim().to_ascii_lowercase() { + return Err("应用更新包 SHA-256 校验失败".to_string()); + } + + update_app_task(app, |task| { + task.phase = "staging".to_string(); + task.message = Some("正在准备应用更新".to_string()); + }); + let staging_dir = work_dir.join("staging"); + let package_manifest = extract_portable_update_archive(&archive_path, &staging_dir)?; + if normalize_version(&package_manifest.version) != normalize_version(&pending.version) + || package_manifest.platform != "windows" + || package_manifest.arch != pending.arch + { + return Err("应用更新包版本或架构不匹配".to_string()); + } + + let current_exe = + env::current_exe().map_err(|error| format!("读取当前程序路径失败: {error}"))?; + let app_dir = current_exe + .parent() + .ok_or_else(|| "当前程序路径没有父目录".to_string())?; + preflight_portable_update_directory(app_dir)?; + let helper_path = work_dir.join("EasyCLIProxyAPI-updater.exe"); + fs::copy(¤t_exe, &helper_path) + .map_err(|error| format!("准备应用更新助手失败: {error}"))?; + + let descriptor = PortableUpdateDescriptor { + parent_pid: std::process::id(), + current_exe: current_exe.clone(), + staged_exe: staging_dir.join(PORTABLE_APP_BINARY), + current_manifest: app_dir.join(PORTABLE_APP_MANIFEST_FILE), + staged_manifest: staging_dir.join(PORTABLE_APP_MANIFEST_FILE), + backup_exe: app_dir.join(".EasyCLIProxyAPI.exe.update-backup"), + backup_manifest: app_dir.join(".portable-app.json.update-backup"), + ack_path: work_dir.join("update-started.ack"), + work_dir: work_dir.clone(), + target_version: pending.version.clone(), + }; + let descriptor_path = work_dir.join("update-descriptor.json"); + fs::write( + &descriptor_path, + serde_json::to_vec_pretty(&descriptor) + .map_err(|error| format!("序列化应用更新描述失败: {error}"))?, + ) + .map_err(|error| format!("写入应用更新描述失败: {error}"))?; + + let mut command = Command::new(&helper_path); + command + .arg("--portable-update-helper") + .arg(&descriptor_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + command + .spawn() + .map_err(|error| format!("启动应用更新助手失败: {error}"))?; + + update_app_task(app, |task| { + task.cancellable = false; + task.phase = "restarting".to_string(); + task.message = Some("更新已准备完成,应用即将重启".to_string()); + }); + tokio::time::sleep(Duration::from_millis(350)).await; + app.exit(0); + Ok(()) + } + .await; + + if result.is_err() { + let _ = fs::remove_dir_all(&work_dir); + } + result + } +} + +#[cfg(windows)] +async fn download_portable_update_archive( + app: &tauri::AppHandle, + pending: &PendingAppUpdate, + token: &CancellationToken, + destination: &Path, +) -> Result<(), String> { + let client = reqwest::Client::builder() + .redirect(github_https_redirect_policy()) + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(15 * 60)) + .build() + .map_err(|error| format!("创建应用更新下载客户端失败: {error}"))?; + let response = client + .get(&pending.asset.url) + .header(reqwest::header::ACCEPT, "application/octet-stream") + .header(reqwest::header::USER_AGENT, APP_USER_AGENT) + .send() + .await + .map_err(|error| format!("下载应用更新失败: {error}"))? + .error_for_status() + .map_err(|error| format!("下载应用更新失败: {error}"))?; + let mut stream = response.bytes_stream(); + let mut file = + File::create(destination).map_err(|error| format!("创建应用更新临时文件失败: {error}"))?; + let mut downloaded = 0_u64; + while let Some(chunk) = stream.next().await { + if token.is_cancelled() { + return Err("应用更新下载已取消".to_string()); + } + let chunk = chunk.map_err(|error| format!("读取应用更新下载数据失败: {error}"))?; + file.write_all(&chunk) + .map_err(|error| format!("写入应用更新临时文件失败: {error}"))?; + downloaded = downloaded.saturating_add(chunk.len() as u64); + if downloaded > pending.asset.size_bytes { + return Err("应用更新包超过清单声明大小".to_string()); + } + update_app_task(app, |task| { + task.downloaded_bytes = downloaded; + task.total_bytes = Some(pending.asset.size_bytes); + task.percent = Some((downloaded as f64 / pending.asset.size_bytes as f64) * 100.0); + task.message = Some(format!( + "{} / {}", + format_byte_count(downloaded), + format_byte_count(pending.asset.size_bytes) + )); + }); + } + file.flush() + .map_err(|error| format!("保存应用更新临时文件失败: {error}"))?; + if downloaded != pending.asset.size_bytes { + return Err(format!( + "应用更新包大小不匹配: 预期 {},实际 {}", + pending.asset.size_bytes, downloaded + )); + } + Ok(()) +} + +fn update_app_task(app: &tauri::AppHandle, update: F) +where + F: FnOnce(&mut AppUpdateTask), +{ + let state = app.state::(); + let task = state.update_task(update); + let _ = app.emit(APP_UPDATE_PROGRESS_EVENT, task); +} + +#[cfg(windows)] +fn format_byte_count(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut value = bytes as f64; + let mut unit = 0_usize; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} {}", UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +#[cfg(windows)] +fn extract_portable_update_archive( + archive_path: &Path, + staging_dir: &Path, +) -> Result { + fs::create_dir_all(staging_dir) + .map_err(|error| format!("创建应用更新暂存目录失败: {error}"))?; + let file = File::open(archive_path).map_err(|error| format!("打开应用更新包失败: {error}"))?; + let mut archive = + ZipArchive::new(file).map_err(|error| format!("读取应用更新 ZIP 失败: {error}"))?; + if archive.len() != 2 { + return Err("应用更新包必须只包含程序和便携版标识".to_string()); + } + let mut seen_binary = false; + let mut seen_manifest = false; + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("读取应用更新 ZIP 条目失败: {error}"))?; + let name = entry.name().replace('\\', "/"); + if entry.is_dir() || entry.enclosed_name().is_none() { + return Err("应用更新包包含不安全路径".to_string()); + } + if entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err("应用更新包不能包含符号链接".to_string()); + } + let destination = match name.as_str() { + PORTABLE_APP_BINARY if !seen_binary => { + if entry.size() == 0 || entry.size() > 256 * 1024 * 1024 { + return Err("应用更新程序大小异常".to_string()); + } + seen_binary = true; + staging_dir.join(PORTABLE_APP_BINARY) + } + PORTABLE_APP_MANIFEST_FILE if !seen_manifest => { + if entry.size() == 0 || entry.size() > 64 * 1024 { + return Err("应用更新标识大小异常".to_string()); + } + seen_manifest = true; + staging_dir.join(PORTABLE_APP_MANIFEST_FILE) + } + _ => return Err(format!("应用更新包包含未知文件: {name}")), + }; + let mut output = File::create(&destination) + .map_err(|error| format!("创建应用更新暂存文件失败: {error}"))?; + io::copy(&mut entry, &mut output) + .map_err(|error| format!("解压应用更新文件失败: {error}"))?; + output + .flush() + .map_err(|error| format!("保存应用更新暂存文件失败: {error}"))?; + } + if !seen_binary || !seen_manifest { + return Err("应用更新包缺少必要文件".to_string()); + } + let manifest = fs::read_to_string(staging_dir.join(PORTABLE_APP_MANIFEST_FILE)) + .map_err(|error| format!("读取应用更新标识失败: {error}"))?; + let manifest = serde_json::from_str::(&manifest) + .map_err(|error| format!("解析应用更新标识失败: {error}"))?; + if manifest.schema_version != 1 + || manifest.application != "EasyCLIProxyAPI" + || !manifest.auto_update + { + return Err("应用更新标识无效".to_string()); + } + Ok(manifest) +} + +#[cfg(windows)] +fn preflight_portable_update_directory(app_dir: &Path) -> Result<(), String> { + let probe = app_dir.join(format!( + ".easycliproxy-update-write-test-{}", + std::process::id() + )); + fs::write(&probe, b"update-write-test") + .map_err(|error| format!("应用目录不可写,无法自动升级: {error}"))?; + fs::remove_file(&probe).map_err(|error| format!("清理应用更新写入测试失败: {error}"))?; + Ok(()) +} + #[tauri::command] async fn check_latest_core() -> Result { let platform = current_core_platform()?; @@ -4954,6 +5720,33 @@ fn install_bundled_core( result } +fn core_needs_bundled_bootstrap(install_dir: &Path) -> bool { + find_core_binary(install_dir).is_none() +} + +fn auto_install_bundled_core_if_missing(app: &tauri::AppHandle) -> Result { + let install_dir = core_install_dir()?; + if !core_needs_bundled_bootstrap(&install_dir) { + return Ok(false); + } + + let (info, archive_path) = bundled_core_archive()? + .ok_or_else(|| "未检测到 CPA 内核,且当前发行包没有匹配的离线内核".to_string())?; + let window = app + .get_webview_window("main") + .map(|webview| webview.as_ref().window()) + .ok_or_else(|| "无法获取主窗口,不能自动安装离线内核".to_string())?; + let state = app.state::(); + state.start(CancellationToken::new(), Some(info.version.clone()))?; + let result = install_bundled_core_inner(&window, state.inner(), &info, &archive_path); + if result.is_err() { + let _ = cleanup_core_work_dirs(); + } + state.finish(&window, result.clone()); + result?; + Ok(true) +} + #[tauri::command] fn cancel_core_install(state: tauri::State<'_, CoreDownloadState>) { state.cancel(); @@ -7876,12 +8669,19 @@ fn ensure_fixed_oauth_dir() -> Result { Ok(directory) } +fn should_import_core_api_keys(had_existing_gui_config: bool, core_api_keys: &[String]) -> bool { + had_existing_gui_config || !core_api_keys.is_empty() +} + fn load_or_create_gui_config() -> Result { ensure_fixed_oauth_dir()?; let config_path = gui_config_path()?; let legacy_config_path = legacy_gui_config_path()?; + let gui_config_exists = config_path.is_file(); + let legacy_config_exists = legacy_config_path.is_file(); + let had_existing_gui_config = gui_config_exists || legacy_config_exists; - let (mut config, presence, mut changed) = if config_path.is_file() { + let (mut config, presence, mut changed) = if gui_config_exists { let content = fs::read_to_string(&config_path) .map_err(|err| format!("读取 GUI 配置失败 {}: {err}", path_to_string(&config_path)))?; let config = toml::from_str::(&content) @@ -7889,7 +8689,7 @@ fn load_or_create_gui_config() -> Result { let presence = toml::from_str::(&content) .map_err(|err| format!("解析 GUI 配置字段失败: {err}"))?; (config, presence, false) - } else if legacy_config_path.is_file() { + } else if legacy_config_exists { let content = fs::read_to_string(&legacy_config_path).map_err(|err| { format!( "读取旧 GUI 配置失败 {}: {err}", @@ -7911,7 +8711,9 @@ fn load_or_create_gui_config() -> Result { || presence.routing_strategy.is_none(); if missing_core_settings { if let Ok(core_settings) = read_installed_core_config_settings() { - if presence.api_keys.is_none() { + if presence.api_keys.is_none() + && should_import_core_api_keys(had_existing_gui_config, &core_settings.api_keys) + { config.api_keys = merge_core_api_keys_with_gui_metadata( &config.api_keys, &core_settings.api_keys, @@ -7955,10 +8757,10 @@ fn apply_core_settings_to_gui_config( config.routing_strategy = core_settings.routing_strategy.clone(); } -fn built_in_api_key_entry() -> GuiApiKeyEntry { +fn default_api_key_entry() -> GuiApiKeyEntry { GuiApiKeyEntry { key: DEFAULT_API_KEY.to_string(), - remark: DEFAULT_API_KEY_REMARK.to_string(), + remark: DEFAULT_API_KEY_INITIAL_REMARK.to_string(), } } @@ -7971,11 +8773,11 @@ fn merge_core_api_keys_with_gui_metadata( core_api_keys: &[String], added_api_key: Option<&GuiApiKeyEntry>, ) -> Vec { - let mut merged = vec![built_in_api_key_entry()]; + let mut merged: Vec = Vec::new(); for api_key in core_api_keys { let api_key = api_key.trim(); - if api_key.is_empty() || api_key == DEFAULT_API_KEY || is_example_core_api_key(api_key) { + if api_key.is_empty() || is_example_core_api_key(api_key) { continue; } if merged.iter().any(|entry| entry.key == api_key) { @@ -8001,6 +8803,123 @@ fn merge_core_api_keys_with_gui_metadata( merged } +fn normalized_saved_window_size(width: u32, height: u32) -> SavedWindowSize { + SavedWindowSize { + width: width.clamp(MIN_MAIN_WINDOW_WIDTH, MAX_SAVED_WINDOW_DIMENSION), + height: height.clamp(MIN_MAIN_WINDOW_HEIGHT, MAX_SAVED_WINDOW_DIMENSION), + } +} + +fn configured_window_size(config: &GuiConfigFile) -> Option { + match (config.window_width, config.window_height) { + (Some(width), Some(height)) => Some(normalized_saved_window_size(width, height)), + _ => None, + } +} + +fn logical_window_size_from_physical( + physical_size: &tauri::PhysicalSize, + scale_factor: f64, +) -> Option { + if !scale_factor.is_finite() || scale_factor <= 0.0 { + return None; + } + + let width = (f64::from(physical_size.width) / scale_factor).round() as u32; + let height = (f64::from(physical_size.height) / scale_factor).round() as u32; + if width < MIN_MAIN_WINDOW_WIDTH || height < MIN_MAIN_WINDOW_HEIGHT { + return None; + } + + Some(normalized_saved_window_size(width, height)) +} + +fn fit_window_size_to_current_monitor( + window: &tauri::WebviewWindow, + saved_size: SavedWindowSize, +) -> SavedWindowSize { + let monitor = window + .current_monitor() + .ok() + .flatten() + .or_else(|| window.primary_monitor().ok().flatten()); + let Some(monitor) = monitor else { + return saved_size; + }; + + let scale_factor = monitor.scale_factor(); + if !scale_factor.is_finite() || scale_factor <= 0.0 { + return saved_size; + } + + let (frame_width, frame_height) = window + .outer_size() + .ok() + .zip(window.inner_size().ok()) + .map(|(outer, inner)| { + ( + outer.width.saturating_sub(inner.width), + outer.height.saturating_sub(inner.height), + ) + }) + .unwrap_or_default(); + let work_area = monitor.work_area(); + let max_width = + (f64::from(work_area.size.width.saturating_sub(frame_width)) / scale_factor).floor() as u32; + let max_height = (f64::from(work_area.size.height.saturating_sub(frame_height)) / scale_factor) + .floor() as u32; + + SavedWindowSize { + width: saved_size.width.min(max_width.max(MIN_MAIN_WINDOW_WIDTH)), + height: saved_size + .height + .min(max_height.max(MIN_MAIN_WINDOW_HEIGHT)), + } +} + +fn restore_main_window_size(app: &tauri::AppHandle) -> Result<(), String> { + let window_size_state = app.state::(); + let Some(saved_size) = window_size_state.snapshot()? else { + return Ok(()); + }; + let window = app + .get_webview_window("main") + .ok_or_else(|| "主窗口不存在,无法恢复窗口尺寸".to_string())?; + let restored_size = fit_window_size_to_current_monitor(&window, saved_size); + + window + .set_size(LogicalSize::new( + f64::from(restored_size.width), + f64::from(restored_size.height), + )) + .map_err(|error| format!("恢复主窗口尺寸失败: {error}"))?; + window_size_state.replace(restored_size) +} + +fn persist_main_window_size(app: &tauri::AppHandle) -> Result<(), String> { + let window_size_state = app.state::(); + let saved_size = match window_size_state.snapshot()? { + Some(size) => size, + None => { + let window = app + .get_webview_window("main") + .ok_or_else(|| "主窗口不存在,无法保存窗口尺寸".to_string())?; + let physical_size = window + .inner_size() + .map_err(|error| format!("读取主窗口尺寸失败: {error}"))?; + let scale_factor = window + .scale_factor() + .map_err(|error| format!("读取主窗口缩放比例失败: {error}"))?; + logical_window_size_from_physical(&physical_size, scale_factor) + .ok_or_else(|| "主窗口尺寸无效,已跳过保存".to_string())? + } + }; + + app.state::() + .set_window_size(saved_size) + .map(|_| ()) +} + fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { let mut changed = false; let normalized_locale = normalize_app_locale(&config.locale); @@ -8019,9 +8938,6 @@ fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { for entry in &mut config.api_keys { entry.key = entry.key.trim().to_string(); entry.remark = entry.remark.trim().to_string(); - if entry.key == DEFAULT_API_KEY { - entry.remark = DEFAULT_API_KEY_REMARK.to_string(); - } } if config.api_keys != original_api_keys { changed = true; @@ -8030,6 +8946,16 @@ fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { config.management_secret_key = DEFAULT_MANAGEMENT_SECRET_KEY.to_string(); changed = true; } + let window_size = configured_window_size(config); + let normalized_window_width = window_size.map(|size| size.width); + let normalized_window_height = window_size.map(|size| size.height); + if config.window_width != normalized_window_width + || config.window_height != normalized_window_height + { + config.window_width = normalized_window_width; + config.window_height = normalized_window_height; + changed = true; + } let auth_dir = path_to_string(&fixed_oauth_dir()?); if config.auth_dir != auth_dir { config.auth_dir = auth_dir; @@ -8612,7 +9538,7 @@ fn terminate_child(child: &mut Child) -> Result<(), String> { child .wait() .map_err(|err| format!("等待 CPA 内核进程退出失败: {err}"))?; - return Ok(()); + Ok(()) } #[cfg(not(windows))] @@ -8655,10 +9581,10 @@ fn terminate_process_id(process_id: u32) -> Result<(), String> { .map_err(|err| format!("关闭 CPA 内核进程失败: {err}"))?; if status.success() { - return Ok(()); + Ok(()) + } else { + Err(format!("关闭 CPA 内核进程失败: PID {process_id}")) } - - return Err(format!("关闭 CPA 内核进程失败: PID {process_id}")); } #[cfg(not(windows))] @@ -9504,7 +10430,285 @@ fn setup_windows_tray(app: &mut tauri::App) -> tauri::Result<()> { Ok(()) } +#[cfg(windows)] +fn wait_for_windows_process_exit(process_id: u32, timeout: Duration) -> Result<(), String> { + use windows_sys::Win32::{ + Foundation::{CloseHandle, WAIT_FAILED, WAIT_TIMEOUT}, + System::Threading::{OpenProcess, WaitForSingleObject}, + }; + + const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; + let handle = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, process_id) }; + if handle.is_null() { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(87) { + return Ok(()); + } + return Err(format!("打开旧版应用进程失败: {error}")); + } + let timeout_ms = timeout.as_millis().min(u32::MAX as u128) as u32; + let result = unsafe { WaitForSingleObject(handle, timeout_ms) }; + unsafe { CloseHandle(handle) }; + if result == WAIT_TIMEOUT { + return Err("等待旧版应用退出超时".to_string()); + } + if result == WAIT_FAILED { + return Err(format!( + "等待旧版应用退出失败: {}", + io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(windows)] +fn validate_portable_update_descriptor( + descriptor_path: &Path, + descriptor: &PortableUpdateDescriptor, +) -> Result<(), String> { + if descriptor.parent_pid == 0 + || semver::Version::parse(descriptor.target_version.trim().trim_start_matches('v')).is_err() + { + return Err("应用更新描述无效".to_string()); + } + let app_dir = descriptor + .current_exe + .parent() + .ok_or_else(|| "应用更新目标路径无效".to_string())?; + if !descriptor.current_exe.is_absolute() + || descriptor.current_exe != app_dir.join(PORTABLE_APP_BINARY) + || descriptor.current_manifest != app_dir.join(PORTABLE_APP_MANIFEST_FILE) + || descriptor.backup_exe != app_dir.join(".EasyCLIProxyAPI.exe.update-backup") + || descriptor.backup_manifest != app_dir.join(".portable-app.json.update-backup") + { + return Err("应用更新目标路径无效".to_string()); + } + let canonical_work_dir = fs::canonicalize(&descriptor.work_dir) + .map_err(|error| format!("读取应用更新临时目录失败: {error}"))?; + let canonical_temp_dir = fs::canonicalize(env::temp_dir()) + .map_err(|error| format!("读取系统临时目录失败: {error}"))?; + if !canonical_work_dir.starts_with(&canonical_temp_dir) + || !canonical_work_dir + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.starts_with("EasyCLIProxyAPI-update-")) + { + return Err("应用更新工作目录无效".to_string()); + } + let canonical_descriptor = fs::canonicalize(descriptor_path) + .map_err(|error| format!("读取应用更新描述路径失败: {error}"))?; + if canonical_descriptor.parent() != Some(canonical_work_dir.as_path()) + || canonical_descriptor + .file_name() + .and_then(|value| value.to_str()) + != Some("update-descriptor.json") + || descriptor.ack_path != descriptor.work_dir.join("update-started.ack") + { + return Err("应用更新描述路径越界".to_string()); + } + for staged in [&descriptor.staged_exe, &descriptor.staged_manifest] { + let canonical = fs::canonicalize(staged) + .map_err(|error| format!("读取应用更新暂存文件失败: {error}"))?; + if !canonical.starts_with(&canonical_work_dir) { + return Err("应用更新暂存路径越界".to_string()); + } + } + if descriptor.staged_exe + != descriptor + .work_dir + .join("staging") + .join(PORTABLE_APP_BINARY) + || descriptor.staged_manifest + != descriptor + .work_dir + .join("staging") + .join(PORTABLE_APP_MANIFEST_FILE) + { + return Err("应用更新暂存路径无效".to_string()); + } + Ok(()) +} + +#[cfg(windows)] +fn restore_portable_update_backup(descriptor: &PortableUpdateDescriptor) -> Result<(), String> { + if !descriptor.backup_exe.is_file() || !descriptor.backup_manifest.is_file() { + return Err("应用更新备份不完整,无法回滚".to_string()); + } + if descriptor.current_exe.exists() { + fs::remove_file(&descriptor.current_exe) + .map_err(|error| format!("移除新版应用失败: {error}"))?; + } + fs::rename(&descriptor.backup_exe, &descriptor.current_exe) + .map_err(|error| format!("恢复旧版应用失败: {error}"))?; + if descriptor.current_manifest.exists() { + fs::remove_file(&descriptor.current_manifest) + .map_err(|error| format!("移除新版便携版标识失败: {error}"))?; + } + fs::rename(&descriptor.backup_manifest, &descriptor.current_manifest) + .map_err(|error| format!("恢复旧版便携版标识失败: {error}"))?; + Ok(()) +} + +#[cfg(windows)] +fn replace_portable_update_files(descriptor: &PortableUpdateDescriptor) -> Result<(), String> { + let app_dir = descriptor + .current_exe + .parent() + .ok_or_else(|| "应用更新目标路径无效".to_string())?; + let replacement_exe = app_dir.join(".EasyCLIProxyAPI.exe.update-new"); + let replacement_manifest = app_dir.join(".portable-app.json.update-new"); + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + fs::copy(&descriptor.staged_exe, &replacement_exe) + .map_err(|error| format!("准备新版应用程序失败: {error}"))?; + if let Err(error) = fs::copy(&descriptor.staged_manifest, &replacement_manifest) { + let _ = fs::remove_file(&replacement_exe); + return Err(format!("准备新版便携版标识失败: {error}")); + } + + let _ = fs::remove_file(&descriptor.backup_exe); + let _ = fs::remove_file(&descriptor.backup_manifest); + if let Err(error) = fs::rename(&descriptor.current_exe, &descriptor.backup_exe) { + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + return Err(format!("备份旧版应用失败: {error}")); + } + if let Err(error) = fs::rename(&descriptor.current_manifest, &descriptor.backup_manifest) { + let _ = fs::rename(&descriptor.backup_exe, &descriptor.current_exe); + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + return Err(format!("备份便携版标识失败: {error}")); + } + + let replace_result = (|| -> Result<(), String> { + fs::rename(&replacement_exe, &descriptor.current_exe) + .map_err(|error| format!("替换应用程序失败: {error}"))?; + fs::rename(&replacement_manifest, &descriptor.current_manifest) + .map_err(|error| format!("替换便携版标识失败: {error}"))?; + Ok(()) + })(); + if let Err(error) = replace_result { + let _ = fs::remove_file(&replacement_exe); + let _ = fs::remove_file(&replacement_manifest); + restore_portable_update_backup(descriptor) + .map_err(|rollback| format!("{error};{rollback}"))?; + return Err(error); + } + Ok(()) +} + +#[cfg(windows)] +fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), String> { + let descriptor = serde_json::from_slice::( + &fs::read(descriptor_path).map_err(|error| format!("读取应用更新描述失败: {error}"))?, + ) + .map_err(|error| format!("解析应用更新描述失败: {error}"))?; + validate_portable_update_descriptor(descriptor_path, &descriptor)?; + wait_for_windows_process_exit(descriptor.parent_pid, Duration::from_secs(120))?; + + if let Err(error) = replace_portable_update_files(&descriptor) { + let mut rollback = Command::new(&descriptor.current_exe); + configure_background_command(&mut rollback); + let _ = rollback.spawn(); + return Err(error); + } + + let _ = fs::remove_file(&descriptor.ack_path); + let mut command = Command::new(&descriptor.current_exe); + command + .arg("--portable-update-ack") + .arg(&descriptor.ack_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + restore_portable_update_backup(&descriptor)?; + let mut rollback = Command::new(&descriptor.current_exe); + configure_background_command(&mut rollback); + let _ = rollback.spawn(); + return Err(format!("启动新版应用失败: {error}")); + } + }; + + let deadline = Instant::now() + Duration::from_secs(60); + let mut confirmed = false; + while Instant::now() < deadline { + if descriptor.ack_path.is_file() { + confirmed = true; + break; + } + if child + .try_wait() + .map_err(|error| format!("检查新版应用状态失败: {error}"))? + .is_some() + { + break; + } + thread::sleep(Duration::from_millis(200)); + } + + if confirmed { + let _ = fs::remove_file(&descriptor.backup_exe); + let _ = fs::remove_file(&descriptor.backup_manifest); + let _ = fs::remove_file(&descriptor.ack_path); + let _ = fs::remove_file(descriptor_path); + return Ok(()); + } + + let _ = child.kill(); + let _ = child.wait(); + restore_portable_update_backup(&descriptor)?; + let mut rollback = Command::new(&descriptor.current_exe); + configure_background_command(&mut rollback); + let _ = rollback.spawn(); + Err(format!( + "新版应用 {} 未能在 60 秒内完成启动确认,已回滚", + descriptor.target_version + )) +} + +fn portable_update_ack_argument() -> Option { + let mut args = env::args_os(); + while let Some(argument) = args.next() { + if argument == "--portable-update-ack" { + let path = PathBuf::from(args.next()?); + let parent = path.parent()?; + let valid_name = + path.file_name().and_then(|value| value.to_str()) == Some("update-started.ack"); + let valid_parent = parent.starts_with(env::temp_dir()) + && parent + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.starts_with("EasyCLIProxyAPI-update-")); + return (valid_name && valid_parent).then_some(path); + } + } + None +} + fn main() { + #[cfg(windows)] + { + let mut args = env::args_os(); + while let Some(argument) = args.next() { + if argument == "--portable-update-helper" { + let result = args + .next() + .map(PathBuf::from) + .ok_or_else(|| "应用更新助手缺少描述文件".to_string()) + .and_then(|path| run_portable_update_helper(&path)); + if let Err(error) = result { + eprintln!("{error}"); + } + return; + } + } + } + + let portable_update_ack = portable_update_ack_argument(); let gui_config = match load_or_create_gui_config() { Ok(config) => config, Err(error) => { @@ -9516,6 +10720,7 @@ fn main() { config } }; + let initial_window_size = configured_window_size(&gui_config); let app = tauri::Builder::default() .plugin( @@ -9524,11 +10729,40 @@ fn main() { .build(), ) .manage(CoreDownloadState::default()) + .manage(AppUpdateState::default()) .manage(CoreProcessState::default()) .manage(usage::UsageCollectorState::default()) .manage(GuiConfigState::new(gui_config)) + .manage(MainWindowSizeState::new(initial_window_size)) .manage(AgentConfigStatusCache::default()); + let app = app.on_window_event(|window, event| { + if window.label() != "main" { + return; + } + + let observed_size = match event { + tauri::WindowEvent::Resized(physical_size) => { + window.scale_factor().ok().and_then(|scale_factor| { + logical_window_size_from_physical(physical_size, scale_factor) + }) + } + tauri::WindowEvent::ScaleFactorChanged { + scale_factor, + new_inner_size, + .. + } => logical_window_size_from_physical(new_inner_size, *scale_factor), + _ => None, + }; + + if let Some(observed_size) = observed_size { + let window_size_state = window.state::(); + if let Err(error) = window_size_state.replace(observed_size) { + eprintln!("记录主窗口尺寸失败: {error}"); + } + } + }); + #[cfg(target_os = "macos")] let app = app.on_window_event(|window, event| { if window.label() == "main" { @@ -9558,7 +10792,11 @@ fn main() { }); let app = app - .setup(|app| { + .setup(move |app| { + if let Err(error) = restore_main_window_size(app.handle()) { + eprintln!("{error}"); + } + #[cfg(target_os = "macos")] setup_macos_tray(app)?; #[cfg(target_os = "windows")] @@ -9593,6 +10831,12 @@ fn main() { return; }; + match auto_install_bundled_core_if_missing(&core_app) { + Ok(true) => eprintln!("未检测到 CPA 内核,已自动安装内置离线版本"), + Ok(false) => {} + Err(error) => eprintln!("自动安装 CPA 离线内核失败: {error}"), + } + if config.run_on_startup { if let Err(error) = start_core_process_inner(process_state.inner(), &config) { eprintln!("自动启动 CPA 内核失败: {error}"); @@ -9606,6 +10850,11 @@ fn main() { } }); + if let Some(ack_path) = portable_update_ack.as_ref() { + fs::write(ack_path, env!("CARGO_PKG_VERSION").as_bytes()) + .map_err(|error| format!("写入应用更新启动确认失败: {error}"))?; + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -9629,6 +10878,7 @@ fn main() { save_gui_settings, get_core_config_settings, add_core_api_key, + update_core_api_key, delete_core_api_key, set_core_management_secret_key, clear_core_management_secret_key, @@ -9642,6 +10892,9 @@ fn main() { submit_oauth_callback, open_external_url, check_app_update, + get_app_update_task, + start_app_update, + cancel_app_update, check_latest_core, detect_bundled_core, install_core_version, @@ -9659,13 +10912,19 @@ fn main() { .build(tauri::generate_context!()) .expect("failed to build app"); - app.run(|app_handle, event| { - if matches!(event, tauri::RunEvent::Exit) { + app.run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { .. } => { + if let Err(error) = persist_main_window_size(app_handle) { + eprintln!("保存主窗口尺寸失败: {error}"); + } + } + tauri::RunEvent::Exit => { usage::stop_usage_collector(app_handle); let process_state = app_handle.state::(); let gui_config_state = app_handle.state::(); shutdown_managed_core(process_state.inner(), gui_config_state.inner()); } + _ => {} }); } @@ -9740,12 +10999,66 @@ mod tests { assert!(content.contains("auth-dir = ")); assert!(content.contains("[[api-keys]]")); assert!(content.contains("key = \"123456\"")); - assert!(content.contains("remark = \"内置密钥\"")); + assert!(content.contains("remark = \"默认密钥\"")); assert!(content.contains("management-secret-key = \"123456\"")); assert!(content.contains("plugins-enabled = false")); assert!(content.contains("routing-strategy = \"round-robin\"")); } + #[test] + fn gui_window_size_round_trips_through_portable_config() { + let config = GuiConfigFile { + window_width: Some(1440), + window_height: Some(900), + ..GuiConfigFile::default() + }; + + let content = toml::to_string_pretty(&config).unwrap(); + let restored = toml::from_str::(&content).unwrap(); + + assert!(content.contains("window-width = 1440")); + assert!(content.contains("window-height = 900")); + assert_eq!( + configured_window_size(&restored), + Some(SavedWindowSize { + width: 1440, + height: 900, + }) + ); + } + + #[test] + fn gui_window_size_is_clamped_and_requires_both_dimensions() { + let mut config = GuiConfigFile { + window_width: Some(320), + window_height: Some(30_000), + ..GuiConfigFile::default() + }; + + assert!(sanitize_gui_config(&mut config).unwrap()); + assert_eq!(config.window_width, Some(MIN_MAIN_WINDOW_WIDTH)); + assert_eq!(config.window_height, Some(MAX_SAVED_WINDOW_DIMENSION)); + + config.window_height = None; + assert!(sanitize_gui_config(&mut config).unwrap()); + assert_eq!(config.window_width, None); + assert_eq!(config.window_height, None); + } + + #[test] + fn physical_window_size_uses_display_scale_and_ignores_minimized_sizes() { + let physical_size = tauri::PhysicalSize::new(1500, 1000); + assert_eq!( + logical_window_size_from_physical(&physical_size, 1.25), + Some(SavedWindowSize { + width: 1200, + height: 800, + }) + ); + assert!(logical_window_size_from_physical(&tauri::PhysicalSize::new(0, 0), 1.0).is_none()); + assert!(logical_window_size_from_physical(&physical_size, 0.0).is_none()); + } + #[test] fn claude_agent_config_preserves_existing_fields() { let rendered = build_claude_agent_config( @@ -10736,7 +12049,7 @@ mod tests { } #[test] - fn legacy_string_api_keys_gain_remarks_and_keep_custom_keys() { + fn legacy_string_api_keys_keep_custom_keys_without_special_protection() { let legacy = "port = 8317\nallow-lan = false\nrun-on-startup = false\nauth-dir = \"/tmp/oauth\"\napi-keys = [\"123456\", \"custom-key\"]\nmanagement-secret-key = \"123456\"\nplugins-enabled = false\nrouting-strategy = \"round-robin\"\n"; let mut config = toml::from_str::(legacy).unwrap(); @@ -10745,12 +12058,11 @@ mod tests { gui_api_key_values(&config.api_keys), vec!["123456", "custom-key"] ); - assert_eq!(config.api_keys[0].remark, DEFAULT_API_KEY_REMARK); + assert!(config.api_keys[0].remark.is_empty()); assert!(config.api_keys[1].remark.is_empty()); let serialized = toml::to_string_pretty(&config).unwrap(); assert!(serialized.contains("[[api-keys]]")); - assert!(serialized.contains("remark = \"内置密钥\"")); let reparsed = toml::from_str::(&serialized).unwrap(); assert_eq!(reparsed.api_keys, config.api_keys); } @@ -10758,7 +12070,7 @@ mod tests { #[test] fn api_key_remarks_follow_matching_core_keys() { let existing = vec![ - built_in_api_key_entry(), + default_api_key_entry(), GuiApiKeyEntry { key: "custom-key".to_string(), remark: "开发环境".to_string(), @@ -10768,12 +12080,48 @@ mod tests { let merged = merge_core_api_keys_with_gui_metadata(&existing, &core_keys, None); - assert_eq!( - gui_api_key_values(&merged), - vec!["123456", "custom-key", "new-key"] - ); - assert_eq!(merged[1].remark, "开发环境"); - assert!(merged[2].remark.is_empty()); + assert_eq!(gui_api_key_values(&merged), vec!["custom-key", "new-key"]); + assert_eq!(merged[0].remark, "开发环境"); + assert!(merged[1].remark.is_empty()); + } + + #[test] + fn explicit_empty_api_key_list_stays_empty() { + let existing = vec![default_api_key_entry()]; + assert!(merge_core_api_keys_with_gui_metadata(&existing, &[], None).is_empty()); + + let mut config = GuiConfigFile { + api_keys: Vec::new(), + ..GuiConfigFile::default() + }; + sanitize_gui_config(&mut config).unwrap(); + assert!(config.api_keys.is_empty()); + + let content = toml::to_string_pretty(&config).unwrap(); + let restored = toml::from_str::(&content).unwrap(); + assert!(restored.api_keys.is_empty()); + } + + #[test] + fn fresh_config_keeps_initial_default_unless_core_has_real_keys() { + assert!(!should_import_core_api_keys(false, &[])); + assert!(should_import_core_api_keys( + false, + &["existing-key".to_string()] + )); + assert!(should_import_core_api_keys(true, &[])); + } + + #[test] + fn initial_default_api_key_can_be_edited_and_deleted() { + let mut api_keys = vec![DEFAULT_API_KEY.to_string()]; + + replace_core_api_key_value(&mut api_keys, DEFAULT_API_KEY, "custom-key".to_string()) + .unwrap(); + assert_eq!(api_keys, vec!["custom-key"]); + + remove_core_api_key_value(&mut api_keys, "custom-key").unwrap(); + assert!(api_keys.is_empty()); } #[test] @@ -10781,8 +12129,8 @@ mod tests { let view = serde_json::to_value(CoreConfigView::from(&GuiConfigFile::default())).unwrap(); assert_eq!(view["apiKeys"][0]["apiKey"], DEFAULT_API_KEY); - assert_eq!(view["apiKeys"][0]["remark"], DEFAULT_API_KEY_REMARK); - assert_eq!(view["apiKeys"][0]["builtIn"], true); + assert_eq!(view["apiKeys"][0]["remark"], DEFAULT_API_KEY_INITIAL_REMARK); + assert!(view["apiKeys"][0].get("builtIn").is_none()); } #[test] @@ -10839,10 +12187,7 @@ mod tests { assert!(presence.routing_strategy.is_none()); apply_core_settings_to_gui_config(&mut config, &core_settings); - assert_eq!( - gui_api_key_values(&config.api_keys), - vec!["123456", "existing-key"] - ); + assert_eq!(gui_api_key_values(&config.api_keys), vec!["existing-key"]); assert_eq!(config.management_secret_key, DEFAULT_MANAGEMENT_SECRET_KEY); assert!(config.plugins_enabled); assert_eq!(config.routing_strategy, "fill-first"); @@ -10859,10 +12204,7 @@ mod tests { apply_core_settings_to_gui_config(&mut config, &core_settings); assert_eq!(core_settings.api_keys, vec!["real-key"]); - assert_eq!( - gui_api_key_values(&config.api_keys), - vec!["123456", "real-key"] - ); + assert_eq!(gui_api_key_values(&config.api_keys), vec!["real-key"]); assert_eq!( core_settings.management_secret_key.as_deref(), Some("plain-management-secret") @@ -11206,9 +12548,11 @@ mod tests { port: 9527, allow_lan: true, run_on_startup: false, + window_width: None, + window_height: None, auth_dir: path_to_string(&fixed_oauth_dir().unwrap()), api_keys: vec![ - built_in_api_key_entry(), + default_api_key_entry(), GuiApiKeyEntry { key: "gui-key".to_string(), remark: "测试密钥".to_string(), @@ -11316,6 +12660,232 @@ mod tests { assert!(!is_app_update_available("v0.2.0", "v0.1.9").unwrap()); } + fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset { + let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip"); + PortableUpdateAsset { + url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"), + sha256: "ab".repeat(32), + size_bytes: 1024, + } + } + + fn portable_update_test_manifest(version: &str) -> PortableUpdateManifest { + PortableUpdateManifest { + schema_version: 1, + version: version.to_string(), + published_at: "2026-07-24T00:00:00.000Z".to_string(), + release_url: format!( + "https://github.com/router-for-me/EasyCLIProxyAPI/releases/tag/v{version}" + ), + assets: [ + ( + "windows-amd64".to_string(), + portable_update_test_asset(version, "amd64"), + ), + ( + "windows-aarch64".to_string(), + portable_update_test_asset(version, "aarch64"), + ), + ] + .into_iter() + .collect(), + } + } + + #[test] + fn portable_update_manifest_requires_both_matching_github_assets() { + let manifest = portable_update_test_manifest("1.2.3"); + assert!(validate_portable_update_manifest(&manifest).is_ok()); + + let mut missing_arch = portable_update_test_manifest("1.2.3"); + missing_arch.assets.remove("windows-aarch64"); + assert!(validate_portable_update_manifest(&missing_arch).is_err()); + + let mut invalid_timestamp = portable_update_test_manifest("1.2.3"); + invalid_timestamp.published_at = "not-a-timestamp".to_string(); + assert!(validate_portable_update_manifest(&invalid_timestamp).is_err()); + + let mut foreign_host = portable_update_test_manifest("1.2.3"); + foreign_host + .assets + .get_mut("windows-amd64") + .unwrap() + .url = "https://github.com.example.invalid/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/update.zip".to_string(); + assert!(validate_portable_update_manifest(&foreign_host).is_err()); + + let mut mismatched_tag = portable_update_test_manifest("1.2.3"); + mismatched_tag.assets.get_mut("windows-amd64").unwrap().url = format!( + "{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-update-v1.2.3-Windows-amd64.zip" + ); + assert!(validate_portable_update_manifest(&mismatched_tag).is_err()); + } + + #[test] + fn portable_update_state_supports_cancellation_and_snapshot_recovery() { + let state = AppUpdateState::default(); + let pending = PendingAppUpdate { + version: "1.2.3".to_string(), + asset: portable_update_test_asset("1.2.3", "amd64"), + arch: "amd64".to_string(), + }; + state.set_pending( + Some(pending), + AppUpdateTask { + phase: "available".to_string(), + target_version: Some("1.2.3".to_string()), + ..AppUpdateTask::default() + }, + ); + + let token = CancellationToken::new(); + let started = state.start(token.clone()).unwrap(); + assert_eq!(started.version, "1.2.3"); + let recovered = state.snapshot(); + assert!(recovered.running); + assert!(recovered.cancellable); + assert_eq!(recovered.phase, "downloading"); + + state.cancel(); + assert!(token.is_cancelled()); + let finished = state.finish("cancelled", Some("cancelled".to_string())); + assert!(!finished.running); + assert!(!finished.cancellable); + assert_eq!(state.snapshot().phase, "cancelled"); + } + + #[test] + fn sha256_file_hashes_exact_portable_asset_bytes() { + let root = agent_test_home("portable-sha256"); + let asset = root.join("update.zip"); + fs::write(&asset, b"EasyCLIProxyAPI portable update").unwrap(); + + assert_eq!( + sha256_file(&asset).unwrap(), + "ade7a05bacf7c9144319c0f0cf431700a8883d3f6effd3613c60749dfba1eb52" + ); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(windows)] + fn write_portable_update_zip(path: &Path, entries: &[(&str, &[u8], Option)]) { + use zip::write::SimpleFileOptions; + + let file = File::create(path).unwrap(); + let mut archive = zip::ZipWriter::new(file); + for (name, contents, unix_mode) in entries { + let mut options = SimpleFileOptions::default(); + if let Some(mode) = unix_mode { + options = options.unix_permissions(*mode); + } + archive.start_file(*name, options).unwrap(); + archive.write_all(contents).unwrap(); + } + archive.finish().unwrap(); + } + + #[cfg(windows)] + #[test] + fn portable_update_zip_accepts_only_the_two_expected_regular_files() { + let root = agent_test_home("portable-zip"); + let valid_zip = root.join("valid.zip"); + let manifest = br#"{"schemaVersion":1,"application":"EasyCLIProxyAPI","version":"1.2.3","platform":"windows","arch":"amd64","autoUpdate":true}"#; + write_portable_update_zip( + &valid_zip, + &[ + (PORTABLE_APP_BINARY, b"new executable", None), + (PORTABLE_APP_MANIFEST_FILE, manifest, None), + ], + ); + let extracted = extract_portable_update_archive(&valid_zip, &root.join("valid")).unwrap(); + assert_eq!(extracted.version, "1.2.3"); + assert!(extracted.auto_update); + + let traversal_zip = root.join("traversal.zip"); + write_portable_update_zip( + &traversal_zip, + &[ + ("../EasyCLIProxyAPI.exe", b"malicious", None), + (PORTABLE_APP_MANIFEST_FILE, manifest, None), + ], + ); + assert!(extract_portable_update_archive(&traversal_zip, &root.join("traversal")).is_err()); + + let symlink_zip = root.join("symlink.zip"); + { + use zip::write::SimpleFileOptions; + + let file = File::create(&symlink_zip).unwrap(); + let mut archive = zip::ZipWriter::new(file); + archive + .add_symlink( + PORTABLE_APP_BINARY, + "target.exe", + SimpleFileOptions::default(), + ) + .unwrap(); + archive + .start_file(PORTABLE_APP_MANIFEST_FILE, SimpleFileOptions::default()) + .unwrap(); + archive.write_all(manifest).unwrap(); + archive.finish().unwrap(); + } + assert!(extract_portable_update_archive(&symlink_zip, &root.join("symlink")).is_err()); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(windows)] + #[test] + fn portable_update_replacement_preserves_user_data_and_can_roll_back() { + let root = agent_test_home("portable-replace"); + let app_dir = root.join("app"); + let work_dir = root.join("work"); + let staging = work_dir.join("staging"); + fs::create_dir_all(&app_dir).unwrap(); + fs::create_dir_all(&staging).unwrap(); + fs::write(app_dir.join(PORTABLE_APP_BINARY), b"old exe").unwrap(); + fs::write(app_dir.join(PORTABLE_APP_MANIFEST_FILE), b"old manifest").unwrap(); + fs::write(app_dir.join(GUI_CONFIG_FILE), b"user config").unwrap(); + fs::create_dir_all(app_dir.join(OAUTH_DIR_NAME)).unwrap(); + fs::write(app_dir.join(OAUTH_DIR_NAME).join("account.json"), b"oauth").unwrap(); + fs::write(staging.join(PORTABLE_APP_BINARY), b"new exe").unwrap(); + fs::write(staging.join(PORTABLE_APP_MANIFEST_FILE), b"new manifest").unwrap(); + let descriptor = PortableUpdateDescriptor { + parent_pid: 1, + current_exe: app_dir.join(PORTABLE_APP_BINARY), + staged_exe: staging.join(PORTABLE_APP_BINARY), + current_manifest: app_dir.join(PORTABLE_APP_MANIFEST_FILE), + staged_manifest: staging.join(PORTABLE_APP_MANIFEST_FILE), + backup_exe: app_dir.join(".EasyCLIProxyAPI.exe.update-backup"), + backup_manifest: app_dir.join(".portable-app.json.update-backup"), + ack_path: work_dir.join("update-started.ack"), + work_dir, + target_version: "1.2.3".to_string(), + }; + + replace_portable_update_files(&descriptor).unwrap(); + assert_eq!(fs::read(&descriptor.current_exe).unwrap(), b"new exe"); + assert_eq!( + fs::read(&descriptor.current_manifest).unwrap(), + b"new manifest" + ); + assert_eq!( + fs::read(app_dir.join(GUI_CONFIG_FILE)).unwrap(), + b"user config" + ); + assert_eq!( + fs::read(app_dir.join(OAUTH_DIR_NAME).join("account.json")).unwrap(), + b"oauth" + ); + + restore_portable_update_backup(&descriptor).unwrap(); + assert_eq!(fs::read(&descriptor.current_exe).unwrap(), b"old exe"); + assert_eq!( + fs::read(&descriptor.current_manifest).unwrap(), + b"old manifest" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn synthetic_release_uses_official_asset_names_and_urls() { let release = release_from_tag("7.2.80"); @@ -11426,6 +12996,21 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn bundled_core_bootstrap_runs_only_when_no_core_binary_exists() { + let root = agent_test_home("bundled-bootstrap-detection"); + let install_dir = root.join("cpa-core"); + + assert!(core_needs_bundled_bootstrap(&install_dir)); + + let existing_version = install_dir.join("existing-version"); + fs::create_dir_all(&existing_version).unwrap(); + fs::write(existing_version.join(core_binary_name()), b"existing core").unwrap(); + + assert!(!core_needs_bundled_bootstrap(&install_dir)); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn bundled_core_locations_include_macos_app_resources() { let contents_dir = agent_test_home("bundled-macos-resources") diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index c0b9a13..9a68aa5 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1376,6 +1376,8 @@ mod tests { port: 8317, allow_lan: false, run_on_startup: false, + window_width: None, + window_height: None, auth_dir: String::new(), api_keys: Vec::new(), management_secret_key: "123456".to_string(), @@ -1420,6 +1422,7 @@ mod tests { assert_eq!(busy_timeout, 5_000); assert_eq!(foreign_keys, 1); assert!(root.join(USAGE_DATABASE_FILE).is_file()); + drop(connection); fs::remove_dir_all(root).unwrap(); } @@ -1441,6 +1444,7 @@ mod tests { .unwrap(); assert_eq!(count, 1); + drop(connection); fs::remove_dir_all(root).unwrap(); } @@ -1492,6 +1496,7 @@ mod tests { assert_eq!(count, 2); assert_eq!(marker, "2"); + drop(connection); fs::remove_dir_all(root).unwrap(); } @@ -1519,6 +1524,7 @@ mod tests { assert_eq!(analysis.models[0].key, "gpt-b"); assert_eq!(events.total, 1); assert_eq!(events.items[0].id, "request-2"); + drop(connection); fs::remove_dir_all(root).unwrap(); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e8dd24c..dcfc66e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ "title": "EasyCLIProxyAPI", "width": 1200, "height": 810, - "minWidth": 900, + "minWidth": 640, "minHeight": 600 } ], diff --git a/src/App.tsx b/src/App.tsx index d4230b8..2a1fdca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,12 +10,15 @@ import { Gauge, GitFork, History, + House, Languages, LogIn, MessageCircle, Network, + PackageOpen, ServerCog, Settings, + X, } from 'lucide-react'; import appLogo from './assets/logo.jpg'; import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime'; @@ -29,15 +32,24 @@ import { AgentsPage } from './pages/AgentsPage'; import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage'; import { UsageRecordsPage } from './pages/UsageRecordsPage'; import { languageOptions, useI18n } from './i18n'; +import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate'; +import { appUpdateIndicatorState } from './appUpdateModel'; +import { canOpenAppPage, isAlwaysAvailablePage } from './navigation'; const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG'; const pages = [ { - id: 'kernel', - labelKey: 'app.nav.kernel', - icon: ServerCog, - component: KernelPage, + id: 'home', + labelKey: 'app.nav.home', + icon: House, + component: HomePage, + }, + { + id: 'versions', + labelKey: 'app.nav.versions', + icon: PackageOpen, + component: VersionManagementPage, }, { id: 'config', @@ -97,17 +109,28 @@ type WindowsClosePrompt = { error: string | null; }; +function HomePage() { + return ; +} + +function VersionManagementPage() { + return ; +} + function App() { return ( - - - + + + + + ); } function AppContent() { const { locale, setLocale, t } = useI18n(); - const [active, setActive] = useState('kernel'); + const { info: appUpdateInfo, hasUpdate, processing: appUpdateProcessing } = useAppUpdate(); + const [active, setActive] = useState('home'); const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const [windowsClosePrompt, setWindowsClosePrompt] = useState(null); const closeDialogRef = useRef(null); @@ -121,8 +144,8 @@ function AppContent() { ?? languageOptions[0]; useEffect(() => { - if (!coreRunning && active !== 'kernel') { - setActive('kernel'); + if (!canOpenAppPage(active, coreRunning)) { + setActive('home'); } }, [active, coreRunning]); @@ -187,7 +210,7 @@ function AppContent() { }, [windowsClosePrompt]); const select = (pageId: PageId) => { - if (pageId !== 'kernel' && !coreRunning) { + if (!canOpenAppPage(pageId, coreRunning)) { return; } setActive(pageId); @@ -243,7 +266,10 @@ function AppContent() {