diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 154fd3a..4497b79 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -236,6 +236,11 @@ jobs: bun tauri bundle --bundles app --ci --config src-tauri/tauri.dmg.conf.json app_path="$bundle_dir/macos/EasyCLIProxyAPI.app" + resources_dir="$app_path/Contents/Resources" + test -d "$app_path" + test ! -e "$app_path/Contents/MacOS/oauth" + test ! -e "$resources_dir/oauth" + test -s "$resources_dir/core-version.txt" rm -rf "$staging_dir" mkdir -p "$staging_dir" diff --git a/core-version.txt b/core-version.txt index 3ee33af..1876c3b 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.2.136 +7.2.138 diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 3de0c89..9af0772 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -670,6 +670,246 @@ pub(crate) fn auth_dir_path_for_core(auth_dir: &str, install_dir: &Path) -> Path } } +#[cfg(any(target_os = "macos", test))] +fn normalize_path_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if matches!( + normalized.components().next_back(), + Some(Component::Normal(_)) + ) { + normalized.pop(); + } else if !normalized.has_root() { + normalized.push(component.as_os_str()); + } + } + _ => normalized.push(component.as_os_str()), + } + } + normalized +} + +#[cfg(any(target_os = "macos", test))] +pub(crate) fn auth_dir_is_inside_macos_app_bundle(path: &Path) -> bool { + let normalized = normalize_path_lexically(path); + let mut previous_component_was_app = false; + for component in normalized.components() { + let Component::Normal(name) = component else { + previous_component_was_app = false; + continue; + }; + if previous_component_was_app && name == std::ffi::OsStr::new("Contents") { + return true; + } + previous_component_was_app = Path::new(name) + .extension() + .is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("app")); + } + false +} + +#[cfg(any(target_os = "macos", test))] +fn copy_auth_file_atomically(source: &Path, destination: &Path) -> Result<(), String> { + match fs::symlink_metadata(destination) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "OAuth 迁移目标不是普通文件: {}", + path_to_string(destination) + )); + } + let source_bytes = fs::read(source).map_err(|error| { + format!("读取旧 OAuth 文件失败 {}: {error}", path_to_string(source)) + })?; + let destination_bytes = fs::read(destination).map_err(|error| { + format!( + "读取现有 OAuth 文件失败 {}: {error}", + path_to_string(destination) + ) + })?; + if source_bytes == destination_bytes { + return Ok(()); + } + return Err(format!( + "OAuth 迁移目标已存在不同内容,未覆盖: {}", + path_to_string(destination) + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "检查 OAuth 迁移目标失败 {}: {error}", + path_to_string(destination) + )); + } + } + + use std::sync::atomic::{AtomicU64, Ordering}; + + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + let directory = destination.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(directory).map_err(|error| { + format!( + "创建 OAuth 迁移目录失败 {}: {error}", + path_to_string(directory) + ) + })?; + let file_name = destination + .file_name() + .map(|name| name.to_string_lossy()) + .unwrap_or_else(|| "oauth".into()); + let temporary_path = directory.join(format!( + ".{file_name}.migration.{}.{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + + let copy_result = (|| -> io::Result<()> { + fs::copy(source, &temporary_path)?; + let temporary_file = fs::OpenOptions::new().write(true).open(&temporary_path)?; + temporary_file.sync_all()?; + drop(temporary_file); + fs::rename(&temporary_path, destination) + })(); + if let Err(error) = copy_result { + let _ = fs::remove_file(&temporary_path); + return Err(format!( + "复制 OAuth 文件失败 {} -> {}: {error}", + path_to_string(source), + path_to_string(destination) + )); + } + Ok(()) +} + +#[cfg(any(target_os = "macos", test))] +fn copy_auth_directory(source: &Path, destination: &Path) -> Result<(), String> { + let source_metadata = match fs::symlink_metadata(source) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return fs::create_dir_all(destination).map_err(|create_error| { + format!( + "创建 OAuth 目录失败 {}: {create_error}", + path_to_string(destination) + ) + }); + } + Err(error) => { + return Err(format!( + "检查旧 OAuth 目录失败 {}: {error}", + path_to_string(source) + )); + } + }; + if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { + return Err(format!( + "旧 OAuth 路径不是普通目录: {}", + path_to_string(source) + )); + } + + match fs::symlink_metadata(destination) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(format!( + "OAuth 迁移目标不是普通目录: {}", + path_to_string(destination) + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(destination).map_err(|create_error| { + format!( + "创建 OAuth 迁移目录失败 {}: {create_error}", + path_to_string(destination) + ) + })?; + } + Err(error) => { + return Err(format!( + "检查 OAuth 迁移目录失败 {}: {error}", + path_to_string(destination) + )); + } + } + + for entry in fs::read_dir(source) + .map_err(|error| format!("读取旧 OAuth 目录失败 {}: {error}", path_to_string(source)))? + { + let entry = entry.map_err(|error| { + format!( + "读取旧 OAuth 目录项失败 {}: {error}", + path_to_string(source) + ) + })?; + let file_type = entry.file_type().map_err(|error| { + format!( + "检查旧 OAuth 目录项失败 {}: {error}", + path_to_string(&entry.path()) + ) + })?; + if file_type.is_symlink() { + return Err(format!( + "OAuth 迁移不接受符号链接: {}", + path_to_string(&entry.path()) + )); + } + let target = destination.join(entry.file_name()); + if file_type.is_dir() { + copy_auth_directory(&entry.path(), &target)?; + } else if file_type.is_file() { + copy_auth_file_atomically(&entry.path(), &target)?; + } else { + return Err(format!( + "OAuth 迁移不支持该文件类型: {}", + path_to_string(&entry.path()) + )); + } + } + Ok(()) +} + +#[cfg(any(target_os = "macos", test))] +pub(crate) fn migrate_auth_dir_from_macos_app_bundle( + config: &mut GuiConfigFile, + install_dir: &Path, + persistent_auth_dir: &Path, +) -> Result { + let source = normalize_path_lexically(&auth_dir_path_for_core(&config.auth_dir, install_dir)); + if !auth_dir_is_inside_macos_app_bundle(&source) { + return Ok(false); + } + let destination = normalize_path_lexically(persistent_auth_dir); + if auth_dir_is_inside_macos_app_bundle(&destination) { + return Err(format!( + "OAuth 持久化目录不能位于 macOS 应用包内: {}", + path_to_string(&destination) + )); + } + + copy_auth_directory(&source, &destination)?; + config.auth_dir = DEFAULT_AUTH_DIR.to_string(); + Ok(true) +} + +#[cfg(target_os = "macos")] +fn migrate_packaged_macos_auth_dir(config: &mut GuiConfigFile) -> Result { + let previous_auth_dir = config.auth_dir.clone(); + let install_dir = core_install_dir()?; + let persistent_auth_dir = fixed_oauth_dir()?; + let migrated = + migrate_auth_dir_from_macos_app_bundle(config, &install_dir, &persistent_auth_dir)?; + if migrated { + if let Err(error) = patch_core_auth_dir(&config.auth_dir) { + config.auth_dir = previous_auth_dir; + return Err(format!("更新内核 OAuth 目录失败: {error}")); + } + } + Ok(migrated) +} + pub(crate) fn should_import_core_api_keys( had_existing_gui_config: bool, core_api_keys: &[String], @@ -1105,6 +1345,10 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result Result<(), String> if config.auth_dir.trim().is_empty() || config.auth_dir.chars().any(char::is_control) { return Err("凭证目录不能为空或包含控制字符".to_string()); } + #[cfg(target_os = "macos")] + { + let auth_dir = auth_dir_path_for_core(&config.auth_dir, &core_install_dir()?); + if auth_dir_is_inside_macos_app_bundle(&auth_dir) { + return Err("OAuth 凭证目录不能位于 macOS 应用包内".to_string()); + } + } for entry in &config.api_keys { validate_core_api_key(&entry.key)?; validate_api_key_remark(&entry.remark)?; diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 48934d6..855d6c1 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -117,6 +117,14 @@ pub(crate) fn patch_core_management_secret_key(secret_key: &str) -> Result<(), S }) } +#[cfg(target_os = "macos")] +pub(crate) fn patch_core_auth_dir(auth_dir: &str) -> Result<(), String> { + let auth_dir = auth_dir.to_string(); + patch_existing_core_config(move |document| { + set_core_yaml_top_level_value(document, "auth-dir", serde_norway::Value::String(auth_dir)) + }) +} + pub(crate) fn patch_core_plugins_enabled(enabled: bool) -> Result<(), String> { patch_existing_core_config(|document| { set_core_yaml_nested_value( diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 31e632b..8767923 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1525,6 +1525,7 @@ impl GuiConfigState { .map_err(|_| "GUI 配置状态锁已损坏".to_string())?; let mut config = current.clone(); apply_core_settings_to_gui_config(&mut config, settings); + sanitize_gui_config(&mut config)?; validate_gui_config(&config)?; *current = config.clone(); Ok(config) @@ -1659,6 +1660,7 @@ impl GuiConfigState { .map_err(|_| "GUI 配置状态锁已损坏".to_string())?; let mut config = current.clone(); update(&mut config)?; + sanitize_gui_config(&mut config)?; write_gui_config(&config)?; *current = config.clone(); Ok(config) @@ -2043,6 +2045,11 @@ fn main() { .expect("failed to build app"); app.run(|app_handle, event| match event { + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { + has_visible_windows: false, + .. + } => show_main_window(app_handle), tauri::RunEvent::ExitRequested { .. } => { if let Err(error) = persist_main_window_size(app_handle) { eprintln!("保存主窗口尺寸失败: {error}"); diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 29f1e1d..b264865 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -196,6 +196,134 @@ fn default_auth_directory_is_relative_and_legacy_absolute_value_is_migrated() { fs::remove_dir_all(base_dir).unwrap(); } +#[test] +fn packaged_macos_auth_directory_is_copied_before_config_is_repointed() { + let root = agent_test_home("packaged-macos-auth-migration"); + let contents_dir = root.join("EasyCLIProxyAPI.app").join("Contents"); + let source = contents_dir.join("MacOS").join("oauth"); + let configured_source = contents_dir + .join("MacOS") + .join("unused") + .join("..") + .join("oauth"); + let persistent_root = root + .join("Library") + .join("Application Support") + .join("com.cpa.gui"); + let destination = persistent_root.join(OAUTH_DIR_NAME); + let install_dir = persistent_root.join("cpa-core"); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::write(source.join("account.json"), b"oauth-account").unwrap(); + fs::write(source.join("nested").join("token.json"), b"oauth-token").unwrap(); + let mut config = GuiConfigFile { + auth_dir: path_to_string(&configured_source), + ..GuiConfigFile::default() + }; + + assert!(auth_dir_is_inside_macos_app_bundle(&configured_source)); + assert!( + migrate_auth_dir_from_macos_app_bundle(&mut config, &install_dir, &destination,).unwrap() + ); + + assert_eq!(config.auth_dir, DEFAULT_AUTH_DIR); + assert_eq!( + fs::read(destination.join("account.json")).unwrap(), + b"oauth-account" + ); + assert_eq!( + fs::read(destination.join("nested").join("token.json")).unwrap(), + b"oauth-token" + ); + assert_eq!( + fs::read(source.join("account.json")).unwrap(), + b"oauth-account" + ); + + config.auth_dir = path_to_string(&configured_source); + assert!( + migrate_auth_dir_from_macos_app_bundle(&mut config, &install_dir, &destination).unwrap() + ); + assert_eq!(config.auth_dir, DEFAULT_AUTH_DIR); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn packaged_macos_auth_migration_does_not_overwrite_conflicting_credentials() { + let root = agent_test_home("packaged-macos-auth-conflict"); + let source = root + .join("EasyCLIProxyAPI.app") + .join("Contents") + .join("MacOS") + .join("oauth"); + let persistent_root = root.join("persistent"); + let destination = persistent_root.join(OAUTH_DIR_NAME); + let install_dir = persistent_root.join("cpa-core"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&destination).unwrap(); + fs::write(source.join("account.json"), b"old-app-credential").unwrap(); + fs::write(destination.join("account.json"), b"persistent-credential").unwrap(); + let original_auth_dir = path_to_string(&source); + let mut config = GuiConfigFile { + auth_dir: original_auth_dir.clone(), + ..GuiConfigFile::default() + }; + + let error = migrate_auth_dir_from_macos_app_bundle(&mut config, &install_dir, &destination) + .unwrap_err(); + + assert!(error.contains("未覆盖"), "{error}"); + assert_eq!(config.auth_dir, original_auth_dir); + assert_eq!( + fs::read(destination.join("account.json")).unwrap(), + b"persistent-credential" + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn missing_packaged_macos_auth_directory_is_repointed_to_persistent_storage() { + let root = agent_test_home("missing-packaged-macos-auth"); + let source = root + .join("EasyCLIProxyAPI.app") + .join("Contents") + .join("MacOS") + .join("oauth"); + let persistent_root = root.join("persistent"); + let destination = persistent_root.join(OAUTH_DIR_NAME); + let install_dir = persistent_root.join("cpa-core"); + let mut config = GuiConfigFile { + auth_dir: path_to_string(&source), + ..GuiConfigFile::default() + }; + + assert!( + migrate_auth_dir_from_macos_app_bundle(&mut config, &install_dir, &destination,).unwrap() + ); + + assert_eq!(config.auth_dir, DEFAULT_AUTH_DIR); + assert!(destination.is_dir()); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn external_custom_auth_directory_is_not_migrated() { + let root = agent_test_home("external-custom-auth"); + let install_dir = root.join("cpa-core"); + let destination = root.join(OAUTH_DIR_NAME); + let custom = root.join("custom-auth"); + let mut config = GuiConfigFile { + auth_dir: path_to_string(&custom), + ..GuiConfigFile::default() + }; + + assert!( + !migrate_auth_dir_from_macos_app_bundle(&mut config, &install_dir, &destination,).unwrap() + ); + assert_eq!(config.auth_dir, path_to_string(&custom)); + assert!(!destination.exists()); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn legacy_gui_config_can_seed_managed_core_settings() { let legacy = "port: 8317\nallow-lan: false\nrun-on-startup: true\n";