* fix(macos): restore main window on application reopen (#148)

Handle macOS RunEvent::Reopen when no window is visible. Fixes #147.

* fix(version): update core version to 7.2.138

* fix(macos): persist OAuth credentials across DMG upgrades

Migrate app-bundle auth directories to Application Support before updating configuration and prevent future bundle-local OAuth storage.

Fixes #153

---------

Co-authored-by: Fake Trader <9635792+faketrader@users.noreply.github.com>
This commit is contained in:
lzt404andFake Trader authored and GitHub committed 2026-08-22 01:50:21 +08:00
1 parent ee4a37484e
commit c3f1c6e52c
6 files changed
+400 -1

No files matched your search

+5
View File
@@ -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"
+1 -1
View File
@@ -1 +1 @@
7.2.136
7.2.138
+251
View File
@@ -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<bool, String> {
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<bool, String> {
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<bool, St
config.allow_lan = allow_lan;
changed = true;
}
#[cfg(target_os = "macos")]
{
changed |= migrate_packaged_macos_auth_dir(config)?;
}
let legacy_default_auth_dir = fixed_oauth_dir()?;
if config.auth_dir.trim().is_empty()
|| Path::new(config.auth_dir.trim()) == legacy_default_auth_dir
@@ -1281,6 +1525,13 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> 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)?;
+8
View File
@@ -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(
+7
View File
@@ -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}");
+128
View File
@@ -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";