Dev (#154)
* 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:
1 parent
ee4a37484e
commit
c3f1c6e52c
6 files changed
+400
-1
No files matched your search
@@ -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
@@ -1 +1 @@
|
||||
7.2.136
|
||||
7.2.138
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user