diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2c6047..154fd3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -306,7 +306,6 @@ jobs: for platform in windows linux darwin; do node scripts/manifest.mjs \ --directory artifacts \ - --output "artifacts/portable-update-${platform}.json" \ --platform "$platform" \ --repository "${{ github.repository }}" \ --gitcode-repository "${{ vars.GITCODE_GUI_REPOSITORY }}" \ diff --git a/core-version.txt b/core-version.txt index 1f0c976..3ee33af 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.132 +7.2.136 diff --git a/scripts/manifest.mjs b/scripts/manifest.mjs index 4a06a03..1306c27 100644 --- a/scripts/manifest.mjs +++ b/scripts/manifest.mjs @@ -4,6 +4,17 @@ import { basename, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { validateAppVersion } from './version.mjs'; +export function portableUpdateManifestName(platform) { + const normalizedPlatform = String(platform).trim().toLowerCase(); + if (!['windows', 'linux', 'darwin'].includes(normalizedPlatform)) { + throw new Error(`Unsupported update platform: ${platform}`); + } + // Keep the broken legacy macOS updater on a manifest URL that is no longer published. + return normalizedPlatform === 'darwin' + ? 'portable-update-darwin-v2.json' + : `portable-update-${normalizedPlatform}.json`; +} + export async function generatePortableUpdateManifest({ directory, output, @@ -22,7 +33,10 @@ export async function generatePortableUpdateManifest({ const normalizedPlatform = String(platform).trim().toLowerCase(); const platformSpec = platformSpecs[normalizedPlatform]; if (!platformSpec) throw new Error(`Unsupported update platform: ${platform}`); - const resolvedOutput = resolve(output ?? join(resolvedDirectory, `portable-update-${normalizedPlatform}.json`)); + const resolvedOutput = resolve(output ?? join( + resolvedDirectory, + portableUpdateManifestName(normalizedPlatform), + )); const resolvedRepository = repository ?? 'router-for-me/EasyCLIProxyAPI'; const resolvedGitcodeRepository = String(gitcodeRepository ?? '').trim(); const normalizedRawTag = String(rawTag ?? '').trim(); @@ -99,7 +113,9 @@ async function main() { } const directory = resolve(args.get('--directory') ?? 'artifacts'); const platform = args.get('--platform') ?? 'windows'; - const output = resolve(args.get('--output') ?? join(directory, `portable-update-${platform}.json`)); + const output = resolve( + args.get('--output') ?? join(directory, portableUpdateManifestName(platform)), + ); const manifest = await generatePortableUpdateManifest({ directory, output, diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 0d75290..0ba7a0c 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -830,16 +830,18 @@ pub(crate) fn inspect_agent_config( Err(error) => (false, None, false, false, Some(error)), }; let executable = find_agent_executable(client, home); - // ZCode's desktop executable is an Electron app, not a CLI. Invoking it - // with --version can start the GUI and block discovery, so only probe a - // separately installed command-line entry point here. + // Desktop application executables are not CLIs. Invoking them with + // --version can start their GUI and block discovery, so never probe them + // directly. ZCode may still expose a separate command-line entry point. let cli_version = if client == AgentClient::ZCode { find_named_agent_executable(home, &["zcode"]) .filter(|path| executable.as_ref() != Some(path)) .as_deref() .and_then(read_agent_version) - } else { + } else if should_probe_primary_agent_executable_version(client) { executable.as_deref().and_then(read_agent_version) + } else { + None }; let codex_app_installation = (client == AgentClient::Codex) .then(|| find_codex_app_installation(home)) @@ -855,7 +857,8 @@ pub(crate) fn inspect_agent_config( }; let version = cli_version.clone().or_else(|| app_version.clone()); let installed = version.is_some() - || (client == AgentClient::ZCode && executable.is_some()) + || (matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode) + && executable.is_some()) || codex_app_installation.is_some(); let launch_targets = agent_launch_targets( client, @@ -907,6 +910,10 @@ pub(crate) fn inspect_agent_config( } } +pub(crate) fn should_probe_primary_agent_executable_version(client: AgentClient) -> bool { + !matches!(client, AgentClient::ClaudeDesktop | AgentClient::ZCode) +} + pub(crate) fn agent_launch_targets( client: AgentClient, executable: Option<&Path>, @@ -1333,11 +1340,24 @@ pub(crate) fn read_claude_desktop_version(home: &Path) -> Option { #[cfg(target_os = "windows")] { read_windows_claude_desktop_store_version().or_else(|| { - find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path)) + find_claude_desktop_executable(home) + .and_then(|path| read_windows_executable_version(&path)) }) } - #[cfg(not(target_os = "windows"))] - find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path)) + #[cfg(target_os = "macos")] + { + find_claude_desktop_executable(home).and_then(|path| { + let application = path.ancestors().find(|candidate| { + candidate.extension().and_then(|value| value.to_str()) == Some("app") + })?; + read_macos_app_version(application) + }) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = home; + None + } } pub(crate) fn read_codex_app_installation_version(installation: &CodexAppTarget) -> Option { diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index adf42a7..13c089f 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -27,9 +27,6 @@ pub(crate) fn launch_agent( if !status.installed { return Err("未检测到 Pi CLI,请先安装 Pi 并重新检测".to_string()); } - if !status.configured { - return Err("请先安装并配置 Pi CLIProxyAPI provider 插件".to_string()); - } let executable = find_pi_executable(&home).ok_or_else(|| "未找到 Pi CLI 可执行文件".to_string())?; let launch_directory = resolve_launch_directory(working_directory.as_deref(), &home)?; @@ -44,11 +41,6 @@ pub(crate) fn launch_agent( if !status.installed { return Err(format!("未检测到 {},请先安装并重新检测", client.name())); } - validate_agent_launch_configuration(client, &status)?; - if client == AgentClient::Codex && status.oauth_configuration { - validate_codex_oauth_login(&home)?; - } - let default_target = match client { AgentClient::ClaudeDesktop | AgentClient::ZCode => "app", AgentClient::Codex if find_codex_app_installation(&home).is_some() => "app", @@ -88,27 +80,11 @@ pub(crate) fn launch_agent( } #[tauri::command] -pub(crate) async fn restart_codex_app( - app: tauri::AppHandle, - gui_config_state: tauri::State<'_, GuiConfigState>, -) -> Result<(), String> { +pub(crate) async fn restart_codex_app(app: tauri::AppHandle) -> Result<(), String> { let home = app .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; - let config = gui_config_state.snapshot()?; - let status = inspect_agent_config( - AgentClient::Codex, - &home, - config.port, - effective_agent_api_key(&config), - ); - if !status.installed { - return Err("未检测到 Codex,请先安装并重新检测".to_string()); - } - if status.oauth_configuration { - validate_codex_oauth_login(&home)?; - } let target = find_codex_app_installation(&home) .ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?; @@ -120,21 +96,6 @@ pub(crate) async fn restart_codex_app( .map_err(|error| format!("重启 Codex App 任务失败: {error}"))? } -fn validate_agent_launch_configuration( - client: AgentClient, - status: &AgentConfigStatus, -) -> Result<(), String> { - if client == AgentClient::Codex - || (status.modification_enabled && status.modification_state == "applied") - { - return Ok(()); - } - Err(format!( - "请先为 {} 应用配置修改,确保 CPA 配置生效后再启动", - client.name() - )) -} - fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result { let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(fallback.to_path_buf()); diff --git a/src-tauri/src/app_update.rs b/src-tauri/src/app_update.rs index 2d8cc0d..00de815 100644 --- a/src-tauri/src/app_update.rs +++ b/src-tauri/src/app_update.rs @@ -1,5 +1,8 @@ use super::*; +pub(crate) const PORTABLE_UPDATE_HELPER_ACK_FILE: &str = "update-helper-started.ack"; +const PORTABLE_UPDATE_HELPER_START_TIMEOUT: Duration = Duration::from_secs(10); + #[tauri::command] pub(crate) fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> { open_external_url_inner(&app, &url) @@ -581,34 +584,7 @@ pub(crate) async fn download_and_stage_portable_app_update( 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(()) + launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await } .await; @@ -709,9 +685,6 @@ pub(crate) async fn download_and_stage_portable_app_update( .strip_prefix(¤t_app) .map_err(|_| "macOS 应用程序路径无效".to_string())? .to_path_buf(); - let helper_path = work_dir.join("EasyCLIProxyAPI-updater"); - fs::copy(¤t_exe, &helper_path) - .map_err(|error| format!("准备应用更新助手失败: {error}"))?; let backup_app = current_app .parent() .ok_or_else(|| "macOS 应用程序路径没有父目录".to_string())? @@ -726,6 +699,10 @@ pub(crate) async fn download_and_stage_portable_app_update( work_dir: work_dir.clone(), target_version: pending.version.clone(), }; + // Keep the updater inside its signed application bundle. Copying the Mach-O + // executable into a temporary directory strips the bundle context Gatekeeper + // uses to validate it and can cause macOS to terminate it before it starts. + let helper_path = macos_update_helper_path(¤t_exe); launch_portable_update_helper(app, &helper_path, &work_dir, &descriptor).await } .await; @@ -765,7 +742,12 @@ fn ensure_portable_update_download( Ok(()) } -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "macos", test))] +pub(crate) fn macos_update_helper_path(current_exe: &Path) -> PathBuf { + current_exe.to_path_buf() +} + +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] async fn launch_portable_update_helper( app: &tauri::AppHandle, helper_path: &Path, @@ -779,6 +761,8 @@ async fn launch_portable_update_helper( .map_err(|error| format!("序列化应用更新描述失败: {error}"))?, ) .map_err(|error| format!("写入应用更新描述失败: {error}"))?; + let helper_ack_path = portable_update_helper_ack_path(work_dir); + let _ = fs::remove_file(&helper_ack_path); let mut command = Command::new(helper_path); command .arg("--portable-update-helper") @@ -786,19 +770,68 @@ async fn launch_portable_update_helper( .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - command + configure_background_command(&mut command); + let mut child = command .spawn() .map_err(|error| format!("启动应用更新助手失败: {error}"))?; + wait_for_portable_update_helper_start( + &mut child, + &helper_ack_path, + PORTABLE_UPDATE_HELPER_START_TIMEOUT, + ) + .await?; 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(()) } +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] +pub(crate) fn portable_update_helper_ack_path(work_dir: &Path) -> PathBuf { + work_dir.join(PORTABLE_UPDATE_HELPER_ACK_FILE) +} + +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] +pub(crate) fn acknowledge_portable_update_helper_start(work_dir: &Path) -> Result<(), String> { + fs::write( + portable_update_helper_ack_path(work_dir), + std::process::id().to_string(), + ) + .map_err(|error| format!("写入应用更新助手启动确认失败: {error}")) +} + +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] +async fn wait_for_portable_update_helper_start( + child: &mut Child, + ack_path: &Path, + timeout: Duration, +) -> Result<(), String> { + let deadline = Instant::now() + timeout; + loop { + if ack_path.is_file() { + return Ok(()); + } + if let Some(status) = child + .try_wait() + .map_err(|error| format!("检查应用更新助手状态失败: {error}"))? + { + return Err(format!("应用更新助手未能启动,退出状态: {status}")); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "应用更新助手未能在 {} 秒内完成启动确认", + timeout.as_secs() + )); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + #[cfg(any(windows, target_os = "linux", target_os = "macos"))] pub(crate) async fn download_portable_update_archive( app: &tauri::AppHandle, @@ -1667,6 +1700,7 @@ pub(crate) fn cleanup_portable_update_payload( })); let _ = fs::remove_dir_all(descriptor.work_dir.join("staging")); let _ = fs::remove_file(&descriptor.ack_path); + let _ = fs::remove_file(portable_update_helper_ack_path(&descriptor.work_dir)); let _ = fs::remove_file(descriptor_path); } @@ -1677,7 +1711,13 @@ pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), S ) .map_err(|error| format!("解析应用更新描述失败: {error}"))?; validate_portable_update_descriptor(descriptor_path, &descriptor)?; - wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))?; + acknowledge_portable_update_helper_start(&descriptor.work_dir)?; + if let Err(error) = + wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120)) + { + cleanup_portable_update_payload(descriptor_path, &descriptor); + return Err(error); + } if let Err(error) = replace_portable_update_files(&descriptor) { let mut rollback = Command::new(&descriptor.current_exe); @@ -1792,7 +1832,12 @@ pub(crate) fn stage_macos_application_from_dmg( codesign .args(["--verify", "--deep", "--strict"]) .arg(staged_app); - run_macos_update_command(&mut codesign, "校验新版 macOS 应用签名") + run_macos_update_command(&mut codesign, "校验新版 macOS 应用签名")?; + let mut gatekeeper = Command::new("spctl"); + gatekeeper + .args(["--assess", "--type", "execute", "--verbose=2"]) + .arg(staged_app); + run_macos_update_command(&mut gatekeeper, "校验新版 macOS 应用系统信任状态") })(); let mut detach = Command::new("hdiutil"); @@ -1964,7 +2009,12 @@ pub(crate) fn replace_macos_application(descriptor: &MacosUpdateDescriptor) -> R .current_app .parent() .ok_or_else(|| "macOS 应用更新目标路径无效".to_string())?; - let replacement_app = app_parent.join(".EasyCLIProxyAPI.app.update-new"); + let replacement_app = app_parent.join(".EasyCLIProxyAPI-update-new.app"); + let legacy_replacement_app = app_parent.join(".EasyCLIProxyAPI.app.update-new"); + if legacy_replacement_app.exists() { + fs::remove_dir_all(&legacy_replacement_app) + .map_err(|error| format!("清理旧格式的 macOS 更新暂存失败: {error}"))?; + } if replacement_app.exists() { fs::remove_dir_all(&replacement_app) .map_err(|error| format!("清理旧的 macOS 更新暂存失败: {error}"))?; @@ -1981,6 +2031,16 @@ pub(crate) fn replace_macos_application(descriptor: &MacosUpdateDescriptor) -> R let _ = fs::remove_dir_all(&replacement_app); return Err(error); } + let mut gatekeeper = Command::new("spctl"); + gatekeeper + .args(["--assess", "--type", "execute", "--verbose=2"]) + .arg(&replacement_app); + if let Err(error) = + run_macos_update_command(&mut gatekeeper, "校验待替换 macOS 应用系统信任状态") + { + let _ = fs::remove_dir_all(&replacement_app); + return Err(error); + } if descriptor.backup_app.exists() { fs::remove_dir_all(&descriptor.backup_app) .map_err(|error| format!("清理旧的 macOS 应用备份失败: {error}"))?; @@ -2001,6 +2061,7 @@ fn cleanup_macos_update_payload(descriptor_path: &Path, descriptor: &MacosUpdate let _ = fs::remove_dir_all(descriptor.work_dir.join("staging")); let _ = fs::remove_dir_all(descriptor.work_dir.join("mount")); let _ = fs::remove_file(&descriptor.ack_path); + let _ = fs::remove_file(portable_update_helper_ack_path(&descriptor.work_dir)); let _ = fs::remove_file(descriptor_path); } @@ -2011,7 +2072,13 @@ pub(crate) fn run_portable_update_helper(descriptor_path: &Path) -> Result<(), S ) .map_err(|error| format!("解析应用更新描述失败: {error}"))?; validate_macos_update_descriptor(descriptor_path, &descriptor)?; - wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120))?; + acknowledge_portable_update_helper_start(&descriptor.work_dir)?; + if let Err(error) = + wait_for_portable_parent_exit(descriptor.parent_pid, Duration::from_secs(120)) + { + cleanup_macos_update_payload(descriptor_path, &descriptor); + return Err(error); + } if let Err(error) = replace_macos_application(&descriptor) { let rollback_exe = descriptor .current_app diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5936601..31e632b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -83,8 +83,10 @@ const RELEASE_DOWNLOAD_PREFIX: &str = const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json"; #[cfg(target_os = "linux")] const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-linux.json"; +// Legacy macOS clients contain an updater that cannot launch outside its signed app bundle. +// A separate channel makes those clients fall back to a one-time manual installation. #[cfg(target_os = "macos")] -const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-darwin.json"; +const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-darwin-v2.json"; const APP_RELEASE_DOWNLOAD_PREFIX: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/"; #[cfg(windows)] @@ -92,7 +94,7 @@ const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-windows.json"; #[cfg(target_os = "linux")] const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-linux.json"; #[cfg(target_os = "macos")] -const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-darwin.json"; +const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-darwin-v2.json"; const CODEX_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/codex_models/model-catalog.json"; const CODEX_MODEL_CATALOG_OVERRIDE_DIR: &str = "codex_models"; const CODEX_MODEL_CATALOG_SOURCE_FILE: &str = "model-catalog.json"; diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 85cfe58..fac09a6 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -1148,6 +1148,22 @@ fn kimi_grok_and_zcode_use_cpa_runtime_context_windows() { } } +#[test] +fn desktop_application_binaries_are_not_used_for_cli_version_probes() { + assert!(!should_probe_primary_agent_executable_version( + AgentClient::ClaudeDesktop + )); + assert!(!should_probe_primary_agent_executable_version( + AgentClient::ZCode + )); + assert!(should_probe_primary_agent_executable_version( + AgentClient::ClaudeCode + )); + assert!(should_probe_primary_agent_executable_version( + AgentClient::Codex + )); +} + #[test] fn claude_model_lists_mark_yaml_aliases_when_api_returns_only_model_ids() { let mut models = test_agent_models(&[ diff --git a/src-tauri/src/tests/app_update.rs b/src-tauri/src/tests/app_update.rs index c9bdea0..e32a6a6 100644 --- a/src-tauri/src/tests/app_update.rs +++ b/src-tauri/src/tests/app_update.rs @@ -24,6 +24,33 @@ fn app_update_comparison_uses_semantic_versions() { assert!(!is_app_update_available("v0.2.0", "v0.1.9").unwrap()); } +#[cfg(any(windows, target_os = "linux", target_os = "macos"))] +#[test] +fn portable_update_helper_writes_a_distinct_startup_ack() { + let work_dir = agent_test_home("portable-update-helper-ack"); + acknowledge_portable_update_helper_start(&work_dir).unwrap(); + + let ack_path = portable_update_helper_ack_path(&work_dir); + assert_eq!( + ack_path.file_name().and_then(|value| value.to_str()), + Some(PORTABLE_UPDATE_HELPER_ACK_FILE) + ); + assert_eq!( + fs::read_to_string(&ack_path).unwrap(), + std::process::id().to_string() + ); + assert_ne!(ack_path.file_name().unwrap(), "update-started.ack"); + fs::remove_dir_all(work_dir).unwrap(); +} + +#[test] +fn macos_update_helper_stays_inside_the_signed_app_bundle() { + let current_exe = + PathBuf::from("/Applications/EasyCLIProxyAPI.app/Contents/MacOS/EasyCLIProxyAPI"); + + assert_eq!(macos_update_helper_path(¤t_exe), current_exe); +} + fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset { let (_, display, suffix) = portable_update_asset_platform().unwrap(); let name = format!("EasyCLIProxyAPI-v{version}-{display}-{arch}.{suffix}"); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index bab32f0..c9a5e7a 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1,8 +1,10 @@ mod resp; +#[cfg(target_os = "macos")] +use super::executable_dir; use super::{ - apply_configured_proxy, current_core_status, management_authorization, management_endpoint, - management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState, + apply_configured_proxy, core_base_dir, current_core_status, management_authorization, + management_endpoint, management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState, }; use chrono::{DateTime, Local}; use rusqlite::{ @@ -436,7 +438,73 @@ impl UsageCollectorState { } pub(crate) fn initialize_usage_storage() -> Result<(), String> { - initialize_usage_storage_at(&usage_root_dir()?) + let root = usage_root_dir()?; + migrate_legacy_usage_storage(&root)?; + initialize_usage_storage_at(&root) +} + +#[cfg(target_os = "macos")] +fn migrate_legacy_usage_storage(target: &Path) -> Result<(), String> { + let legacy = executable_dir()?.join(USAGE_DIR_NAME); + migrate_usage_storage_directory(&legacy, target) +} + +#[cfg(not(target_os = "macos"))] +fn migrate_legacy_usage_storage(target: &Path) -> Result<(), String> { + let _ = target; + Ok(()) +} + +#[cfg(any(target_os = "macos", test))] +fn migrate_usage_storage_directory(source: &Path, target: &Path) -> Result<(), String> { + if source == target || !source.is_dir() || target.exists() { + return Ok(()); + } + let target_parent = target + .parent() + .ok_or_else(|| "使用记录目标目录没有父目录".to_string())?; + fs::create_dir_all(target_parent) + .map_err(|error| format!("创建使用记录迁移目标目录失败: {error}"))?; + if fs::rename(source, target).is_ok() { + return Ok(()); + } + + if let Err(error) = copy_usage_storage_directory(source, target) { + let _ = fs::remove_dir_all(target); + return Err(format!("迁移旧版使用记录失败: {error}")); + } + if let Err(error) = fs::remove_dir_all(source) { + eprintln!( + "旧版使用记录已复制,但无法清理原目录 {}: {error}", + source.display() + ); + } + Ok(()) +} + +#[cfg(any(target_os = "macos", test))] +fn copy_usage_storage_directory(source: &Path, target: &Path) -> Result<(), String> { + fs::create_dir_all(target).map_err(|error| format!("创建使用记录目录失败: {error}"))?; + let entries = fs::read_dir(source).map_err(|error| format!("读取旧版使用记录失败: {error}"))?; + for entry in entries { + let entry = entry.map_err(|error| format!("读取旧版使用记录项目失败: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("读取旧版使用记录项目类型失败: {error}"))?; + let destination = target.join(entry.file_name()); + if file_type.is_dir() { + copy_usage_storage_directory(&entry.path(), &destination)?; + } else if file_type.is_file() { + fs::copy(entry.path(), &destination) + .map_err(|error| format!("复制旧版使用记录失败: {error}"))?; + } else { + return Err(format!( + "旧版使用记录包含不支持的文件类型: {}", + entry.path().display() + )); + } + } + Ok(()) } fn initialize_usage_storage_at(root: &Path) -> Result<(), String> { @@ -2729,12 +2797,7 @@ fn query_window_minutes(query: &UsageQuery, first: Option, last: Option Result { - let executable = - std::env::current_exe().map_err(|error| format!("读取程序路径失败: {error}"))?; - let directory = executable - .parent() - .ok_or_else(|| "程序路径没有父目录".to_string())?; - Ok(directory.join(USAGE_DIR_NAME)) + Ok(core_base_dir()?.join(USAGE_DIR_NAME)) } fn record_local_hour(record: &UsageRecord) -> String { @@ -2888,6 +2951,33 @@ mod tests { root } + #[test] + fn legacy_usage_storage_is_moved_with_nested_files() { + let root = test_root("storage-location-migration"); + let source = root.join("legacy").join(USAGE_DIR_NAME); + let target = root.join("application-support").join(USAGE_DIR_NAME); + fs::create_dir_all(source.join(USAGE_BACKUP_DIR_NAME)).unwrap(); + fs::write(source.join(USAGE_DATABASE_FILE), b"database").unwrap(); + fs::write( + source.join(USAGE_BACKUP_DIR_NAME).join("usage.db.backup"), + b"backup", + ) + .unwrap(); + + migrate_usage_storage_directory(&source, &target).unwrap(); + + assert!(!source.exists()); + assert_eq!( + fs::read(target.join(USAGE_DATABASE_FILE)).unwrap(), + b"database" + ); + assert_eq!( + fs::read(target.join(USAGE_BACKUP_DIR_NAME).join("usage.db.backup")).unwrap(), + b"backup" + ); + fs::remove_dir_all(root).unwrap(); + } + fn open_test_database(root: &Path) -> Connection { initialize_usage_storage_at(root).unwrap(); open_usage_database_at(root).unwrap() diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 20190d9..a62709a 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -959,11 +959,7 @@ export function AgentsPage() { const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null; const launchEnabled = Boolean( activeStatus?.supportedPlatform - && activeStatus.installed - && (selected === 'codex' - || (selected === 'pi' - ? activeStatus.configured - : activeStatus.modificationEnabled && activeStatus.modificationState === 'applied')), + && activeStatus.installed, ); const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target); const activeLaunchDirectoryHistory = launchDirectoryHistory[selected] ?? []; @@ -1356,7 +1352,6 @@ export function AgentsPage() { setBusyAction(action); setLaunchError(''); try { - if (draftChanged) throw new Error(t('agents.error.applyFirst')); await invoke('launch_agent', { client: selected, target: target.id, @@ -1391,7 +1386,6 @@ export function AgentsPage() { setBusyAction('restart-app'); setLaunchError(''); try { - if (draftChanged) throw new Error(t('agents.error.applyFirst')); await invoke('restart_codex_app'); } catch (requestError) { if (!handleOAuthLoginError(requestError, 'launch')) { @@ -1887,10 +1881,8 @@ export function AgentsPage() { type="button" className="secondary-button agent-launch-button" onClick={() => void launchAgent(cliLaunchTarget)} - disabled={busy || !canLaunchTarget(cliLaunchTarget) || draftChanged} - title={draftChanged - ? t('agents.launch.applyFirst') - : cliLaunchTarget?.detail ?? t('agents.launch.unavailable')} + disabled={busy || !canLaunchTarget(cliLaunchTarget)} + title={cliLaunchTarget?.detail ?? t('agents.launch.unavailable')} > {busyAction === 'launch-cli' ? @@ -1903,10 +1895,8 @@ export function AgentsPage() { type="button" className="primary-button agent-launch-button" onClick={() => void launchAgent(appLaunchTarget)} - disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged} - title={draftChanged - ? t('agents.launch.applyFirst') - : appLaunchTarget?.detail ?? t('agents.launch.unavailable')} + disabled={busy || !canLaunchTarget(appLaunchTarget)} + title={appLaunchTarget?.detail ?? t('agents.launch.unavailable')} > {busyAction === 'launch-app' ? @@ -1919,10 +1909,8 @@ export function AgentsPage() { type="button" className="secondary-button agent-launch-button" onClick={() => void restartCodexApp()} - disabled={busy || !canLaunchTarget(appLaunchTarget) || draftChanged} - title={draftChanged - ? t('agents.launch.applyFirst') - : appLaunchTarget?.detail ?? t('agents.launch.unavailable')} + disabled={busy || !canLaunchTarget(appLaunchTarget)} + title={appLaunchTarget?.detail ?? t('agents.launch.unavailable')} > {busyAction === 'restart-app' ? @@ -1937,12 +1925,8 @@ export function AgentsPage() { type="button" className="primary-button agent-launch-button" onClick={() => void launchAgent(defaultLaunchTarget)} - disabled={busy || !canLaunchTarget(defaultLaunchTarget) || draftChanged} - title={draftChanged - ? t('agents.launch.applyFirst') - : defaultLaunchTarget?.detail ?? (launchEnabled - ? t('agents.launch.unavailable') - : t('agents.launch.enableFirst'))} + disabled={busy || !canLaunchTarget(defaultLaunchTarget)} + title={defaultLaunchTarget?.detail ?? t('agents.launch.unavailable')} > {busyAction === 'launch' ? diff --git a/tests/portableUpdateManifest.test.ts b/tests/portableUpdateManifest.test.ts index a5e7b95..e92e527 100644 --- a/tests/portableUpdateManifest.test.ts +++ b/tests/portableUpdateManifest.test.ts @@ -5,8 +5,15 @@ import { join } from 'node:path'; import { describe, expect, test } from 'bun:test'; import { generatePortableUpdateManifest, + portableUpdateManifestName, } from '../scripts/manifest.mjs'; +test('macOS uses a new update manifest channel', () => { + expect(portableUpdateManifestName('windows')).toBe('portable-update-windows.json'); + expect(portableUpdateManifestName('linux')).toBe('portable-update-linux.json'); + expect(portableUpdateManifestName('darwin')).toBe('portable-update-darwin-v2.json'); +}); + describe('Windows 便携更新清单', () => { test('URL、大小和哈希与实际上传资产一致', async () => { const root = await mkdtemp(join(tmpdir(), 'easycli-manifest-test-')); @@ -131,10 +138,9 @@ describe('跨平台便携更新清单', () => { `${platform} ${arch} full package`, ); } - const output = join(root, `portable-update-${platform}.json`); + const output = join(root, portableUpdateManifestName(platform)); const manifest = await generatePortableUpdateManifest({ directory: root, - output, platform, repository: 'router-for-me/EasyCLIProxyAPI', tag: 'v1.2.3',