diff --git a/apps/theme-manager/src-tauri/src/lib.rs b/apps/theme-manager/src-tauri/src/lib.rs index 82e786e..f710ec3 100644 --- a/apps/theme-manager/src-tauri/src/lib.rs +++ b/apps/theme-manager/src-tauri/src/lib.rs @@ -47,6 +47,7 @@ struct BootstrapView { catalog: Value, catalog_state: CatalogStatus, targets: Vec, + target_diagnostic: Option, skin_state: skin_runtime::SkinRuntimeView, persistence_state: persistence::PersistenceView, update_state: UpdateView, @@ -150,18 +151,25 @@ async fn bootstrap( .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone(); + let target_discovery = platform::discover_targets(); Ok(BootstrapView { app_version: app.package_info().version.to_string(), platform: platform_name(), catalog: catalog::present_catalog(&app, &catalog)?, catalog_state, - targets: platform::discover_targets(), + targets: target_discovery.targets, + target_diagnostic: target_discovery.diagnostic, skin_state: state.skin_runtime.current(), persistence_state: persistence::current(&app)?, update_state: state.updater.current(), }) } +#[tauri::command] +fn discover_targets() -> platform::TargetDiscovery { + platform::discover_targets() +} + #[tauri::command] async fn refresh_catalog(app: AppHandle, state: State<'_, DesktopState>) -> Result { publish_catalog( @@ -316,6 +324,15 @@ async fn disable_persistent_theme( persistence::disable(&app) } +#[tauri::command] +fn force_restart_persistent_theme( + app: AppHandle, + channel: String, + confirmed: bool, +) -> Result { + persistence::force_restart(&app, &channel, confirmed) +} + #[tauri::command] fn open_external(app: AppHandle, target: String) -> Result { let url = match target.as_str() { @@ -376,6 +393,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ bootstrap, + discover_targets, refresh_catalog, copy_theme, open_codex, @@ -385,6 +403,7 @@ pub fn run() { get_persistence_state, enable_persistent_theme, disable_persistent_theme, + force_restart_persistent_theme, open_external, check_for_app_update, install_app_update diff --git a/apps/theme-manager/src-tauri/src/persistence.rs b/apps/theme-manager/src-tauri/src/persistence.rs index d4a2358..52f98ce 100644 --- a/apps/theme-manager/src-tauri/src/persistence.rs +++ b/apps/theme-manager/src-tauri/src/persistence.rs @@ -485,6 +485,7 @@ fn retry_delay(attempt: u8) -> Option { enum ControllerDecision { Wait, Blocked, + AwaitUserClose, ApplyStopped, Refresh, Active, @@ -500,10 +501,18 @@ fn controller_decision( if phase == "retry-blocked" { return ControllerDecision::Blocked; } + if target_running && matches!(phase, "needs-user-close" | "force-closing") { + return ControllerDecision::AwaitUserClose; + } if !target_running { return if matches!( phase, - APPLY_REQUESTED_PHASE | "restarting" | "retrying" | "error" + APPLY_REQUESTED_PHASE + | "restarting" + | "retrying" + | "error" + | "needs-user-close" + | "force-closing" ) { ControllerDecision::ApplyStopped } else { @@ -531,6 +540,43 @@ async fn wait_until_stopped(target: &platform::TargetView) -> Result<(), String> Err("ChatGPT 没有在安全等待时间内退出".into()) } +fn force_restart_is_authorized(confirmed: bool, phase: &str) -> Result<(), String> { + if !confirmed { + return Err("必须确认未发送的输入可能丢失,才能强制关闭 ChatGPT。".into()); + } + if !matches!(phase, "needs-user-close" | "force-closing") { + return Err("只有正常退出超时后才能请求强制关闭 ChatGPT。".into()); + } + Ok(()) +} + +pub fn force_restart( + app: &AppHandle, + channel: &str, + confirmed: bool, +) -> Result { + let root = persistence_root(app)?; + let selection = read_state_from(&root)?; + if !selection.enabled || selection.channel.as_deref() != Some(channel) { + return Err("所选 ChatGPT 不是当前启用的常驻主题目标。".into()); + } + force_restart_is_authorized(confirmed, &selection.phase)?; + let target = platform::find_target(channel)?; + if !platform::target_is_running(&target)? { + return current(app); + } + platform::force_stop_target(&target)?; + update_status( + &root, + "force-closing", + Some("已请求强制关闭所选 ChatGPT,正在等待其退出后重新应用。".into()), + selection.attempts, + target.version, + selection.tested_version, + )?; + current(app) +} + async fn run_controller(app: AppHandle) -> Result<(), String> { let root = persistence_root(&app)?; fs::create_dir_all(&root).map_err(|error| format!("无法创建主题常驻状态目录:{error}"))?; @@ -629,6 +675,10 @@ async fn run_controller(app: AppHandle) -> Result<(), String> { tokio::time::sleep(POLL_INTERVAL).await; continue; } + ControllerDecision::AwaitUserClose => { + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } ControllerDecision::Wait => { attempts = 0; next_retry = tokio::time::Instant::now(); @@ -798,13 +848,15 @@ async fn run_controller(app: AppHandle) -> Result<(), String> { if let Err(error) = wait_until_stopped(&target).await { update_status( &root, - "retrying", - Some(error), + "needs-user-close", + Some(format!( + "已请求所选 ChatGPT 正常退出,但安全等待超时:{error}" + )), attempts, installed, Some(skin.tested_version), )?; - next_retry = tokio::time::Instant::now() + delay; + next_retry = tokio::time::Instant::now(); continue; } match skin_runtime::apply(&app, &state.skin_runtime, &catalog, theme_id, mode, channel) @@ -978,6 +1030,14 @@ mod tests { controller_decision("retry-blocked", false, false, false), ControllerDecision::Blocked ); + assert_eq!( + controller_decision("needs-user-close", false, false, false), + ControllerDecision::ApplyStopped + ); + assert_eq!( + controller_decision("force-closing", false, false, false), + ControllerDecision::ApplyStopped + ); } #[test] @@ -994,5 +1054,21 @@ mod tests { controller_decision(APPLY_REQUESTED_PHASE, true, false, false), ControllerDecision::Restart ); + assert_eq!( + controller_decision("needs-user-close", true, false, false), + ControllerDecision::AwaitUserClose + ); + assert_eq!( + controller_decision("force-closing", true, false, false), + ControllerDecision::AwaitUserClose + ); + } + + #[test] + fn force_restart_requires_a_second_confirmation_after_graceful_timeout() { + assert!(force_restart_is_authorized(false, "needs-user-close").is_err()); + assert!(force_restart_is_authorized(true, "active").is_err()); + assert!(force_restart_is_authorized(true, "needs-user-close").is_ok()); + assert!(force_restart_is_authorized(true, "force-closing").is_ok()); } } diff --git a/apps/theme-manager/src-tauri/src/platform.rs b/apps/theme-manager/src-tauri/src/platform.rs index 6b24dba..59e91e1 100644 --- a/apps/theme-manager/src-tauri/src/platform.rs +++ b/apps/theme-manager/src-tauri/src/platform.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct TargetView { pub channel: String, @@ -27,8 +27,37 @@ pub struct TargetView { executable_path: Option, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveryDiagnostic { + pub code: String, + pub detail: String, +} + +impl DiscoveryDiagnostic { + fn new(code: &str, detail: impl Into) -> Self { + Self { + code: code.into(), + detail: detail.into(), + } + } + + fn message(&self) -> String { + format!("[{}] {}", self.code, self.detail) + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TargetDiscovery { + pub targets: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostic: Option, +} + #[cfg(target_os = "windows")] const WINDOWS_TARGET_SCRIPT: &str = r#" +$ErrorActionPreference = "Stop" $definitions = @( @{ channel = "stable"; package = "OpenAI.Codex"; executable = "app\ChatGPT.exe"; label = "ChatGPT" }, @{ channel = "beta"; package = "OpenAI.CodexBeta"; executable = "app\ChatGPT (Beta).exe"; label = "ChatGPT Beta" } @@ -43,11 +72,13 @@ foreach ($definition in $definitions) { $applications = @($manifest.Package.Applications.Application | Where-Object { "$($_.Executable)".Replace("/", "\") -ieq $definition.executable }) - if ($applications.Count -ne 1) { continue } + if ($applications.Count -ne 1) { + throw "ACT_MALFORMED_PACKAGE: $($definition.package) has unexpected application metadata" + } $family = "$($package.PackageFamilyName)" $applicationId = "$($applications[0].Id)" if ($family -notmatch "^[A-Za-z0-9._-]{1,128}$" -or $applicationId -notmatch "^[A-Za-z0-9._-]{1,64}$") { - continue + throw "ACT_MALFORMED_PACKAGE: $($definition.package) has an invalid package identity" } $targets += @{ channel = $definition.channel @@ -71,8 +102,59 @@ fn hidden_command(program: &str) -> std::process::Command { command } +#[cfg(any(target_os = "windows", target_os = "macos", test))] +fn discovery_error_code(error: &std::io::Error) -> &'static str { + match error.kind() { + std::io::ErrorKind::NotFound => "command-unavailable", + std::io::ErrorKind::PermissionDenied => "permission-denied", + _ => "command-failed", + } +} + +fn discovery_output_error_code(detail: &str) -> &'static str { + let detail = detail.to_ascii_lowercase(); + if detail.contains("act_malformed_package") { + "malformed-package" + } else if detail.contains("access is denied") + || detail.contains("permission denied") + || detail.contains("operation not permitted") + || detail.contains("拒绝访问") + { + "permission-denied" + } else { + "command-failed" + } +} + +#[cfg(any(target_os = "windows", test))] +fn parse_windows_targets(text: &str) -> Result, DiscoveryDiagnostic> { + if text.trim().is_empty() { + return Ok(Vec::new()); + } + let value: serde_json::Value = serde_json::from_str(text.trim()).map_err(|error| { + DiscoveryDiagnostic::new( + "malformed-output", + format!("Windows 应用检查结果无效:{error}"), + ) + })?; + if value.is_array() { + serde_json::from_value(value).map_err(|error| { + DiscoveryDiagnostic::new("malformed-output", format!("Windows 应用列表无效:{error}")) + }) + } else { + serde_json::from_value(value) + .map(|target| vec![target]) + .map_err(|error| { + DiscoveryDiagnostic::new( + "malformed-output", + format!("Windows 应用记录无效:{error}"), + ) + }) + } +} + #[cfg(target_os = "windows")] -fn discover_platform_targets() -> Result, String> { +fn discover_platform_targets() -> Result, DiscoveryDiagnostic> { let output = hidden_command("powershell.exe") .args([ "-NoProfile", @@ -83,24 +165,31 @@ fn discover_platform_targets() -> Result, String> { WINDOWS_TARGET_SCRIPT, ]) .output() - .map_err(|error| format!("无法检查 Windows ChatGPT 应用:{error}"))?; + .map_err(|error| { + DiscoveryDiagnostic::new( + discovery_error_code(&error), + format!("无法检查 Windows ChatGPT 应用:{error}"), + ) + })?; if !output.status.success() { - return Err("Windows ChatGPT 应用检查失败".into()); - } - let text = String::from_utf8(output.stdout) - .map_err(|error| format!("Windows 应用检查结果不是 UTF-8:{error}"))?; - if text.trim().is_empty() { - return Ok(Vec::new()); - } - let value: serde_json::Value = serde_json::from_str(text.trim()) - .map_err(|error| format!("Windows 应用检查结果无效:{error}"))?; - if value.is_array() { - serde_json::from_value(value).map_err(|error| format!("Windows 应用列表无效:{error}")) - } else { - serde_json::from_value(value) - .map(|target| vec![target]) - .map_err(|error| format!("Windows 应用记录无效:{error}")) + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let code = discovery_output_error_code(&detail); + return Err(DiscoveryDiagnostic::new( + code, + if detail.is_empty() { + "Windows ChatGPT 应用检查失败".into() + } else { + format!("Windows ChatGPT 应用检查失败:{detail}") + }, + )); } + let text = String::from_utf8(output.stdout).map_err(|error| { + DiscoveryDiagnostic::new( + "malformed-output", + format!("Windows 应用检查结果不是 UTF-8:{error}"), + ) + })?; + parse_windows_targets(&text) } #[cfg(any(target_os = "macos", test))] @@ -173,13 +262,17 @@ fn macos_target_from_bundle( } #[cfg(target_os = "macos")] -fn discover_macos_target(channel: &str, label: &str, expected_bundle: &str) -> Option { +fn discover_macos_target( + channel: &str, + label: &str, + expected_bundle: &str, +) -> Result, DiscoveryDiagnostic> { let home = std::env::var("HOME").ok().unwrap_or_default(); let names = macos_application_names(channel); for candidate in macos_named_bundle_paths(&home, names) { if let Some(target) = macos_target_from_bundle(channel, label, expected_bundle, &candidate) { - return Some(target); + return Ok(Some(target)); } } let output = std::process::Command::new("/usr/bin/mdfind") @@ -187,34 +280,67 @@ fn discover_macos_target(channel: &str, label: &str, expected_bundle: &str) -> O "kMDItemCFBundleIdentifier == \"{expected_bundle}\"" )) .output() - .ok()?; + .map_err(|error| { + DiscoveryDiagnostic::new( + discovery_error_code(&error), + format!("无法检查 macOS ChatGPT 应用:{error}"), + ) + })?; if !output.status.success() { - return None; + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(DiscoveryDiagnostic::new( + discovery_output_error_code(&detail), + if detail.is_empty() { + "macOS ChatGPT 应用检查失败".into() + } else { + format!("macOS ChatGPT 应用检查失败:{detail}") + }, + )); } - String::from_utf8(output.stdout) - .ok()? + let output = String::from_utf8(output.stdout).map_err(|error| { + DiscoveryDiagnostic::new( + "malformed-output", + format!("macOS 应用检查结果不是 UTF-8:{error}"), + ) + })?; + Ok(output .lines() - .find_map(|candidate| macos_target_from_bundle(channel, label, expected_bundle, candidate)) + .find_map(|candidate| macos_target_from_bundle(channel, label, expected_bundle, candidate))) } #[cfg(target_os = "macos")] -fn discover_platform_targets() -> Result, String> { - Ok([ +fn discover_platform_targets() -> Result, DiscoveryDiagnostic> { + let mut targets = Vec::new(); + for (channel, label, bundle_id) in [ ("stable", "ChatGPT", "com.openai.codex"), ("beta", "ChatGPT Beta", "com.openai.codex.beta"), - ] - .into_iter() - .filter_map(|(channel, label, bundle_id)| discover_macos_target(channel, label, bundle_id)) - .collect()) + ] { + if let Some(target) = discover_macos_target(channel, label, bundle_id)? { + targets.push(target); + } + } + Ok(targets) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] -fn discover_platform_targets() -> Result, String> { - Ok(Vec::new()) +fn discover_platform_targets() -> Result, DiscoveryDiagnostic> { + Err(DiscoveryDiagnostic::new( + "unsupported-platform", + "当前平台暂不支持 ChatGPT Full Skin 目标检测", + )) } -pub fn discover_targets() -> Vec { - discover_platform_targets().unwrap_or_default() +pub fn discover_targets() -> TargetDiscovery { + match discover_platform_targets() { + Ok(targets) => TargetDiscovery { + targets, + diagnostic: None, + }, + Err(diagnostic) => TargetDiscovery { + targets: Vec::new(), + diagnostic: Some(diagnostic), + }, + } } fn safe_channel(channel: &str) -> bool { @@ -225,10 +351,11 @@ pub fn find_target(channel: &str) -> Result { if !safe_channel(channel) { return Err("未知的 ChatGPT 渠道".into()); } - discover_platform_targets()? + discover_platform_targets() + .map_err(|diagnostic| diagnostic.message())? .into_iter() .find(|target| target.channel == channel) - .ok_or_else(|| "没有检测到所选 ChatGPT 应用".into()) + .ok_or_else(|| "[not-installed] 没有检测到所选 ChatGPT 应用".into()) } fn full_skin_target_can_start_runtime_probe( @@ -244,7 +371,7 @@ pub fn validate_full_skin_target(target: &TargetView) -> Result<(), String> { } if !full_skin_target_can_start_runtime_probe(&target.channel, target.version.as_deref()) { return Err(format!( - "无法读取所选 {} 的准确版本;Full Skin 无法把本机兼容性探测绑定到该应用。", + "[unsupported-version] 无法读取所选 {} 的准确版本;Full Skin 无法把本机兼容性探测绑定到该应用。", target.label )); } @@ -418,7 +545,19 @@ if ($matches.Count -eq 0) { "false" } else { "true" } .env("ACT_PACKAGE", package) .output() .map_err(|error| format!("无法检查 ChatGPT 进程:{error}"))?; - Ok(output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true") + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(if detail.is_empty() { + "无法检查 ChatGPT 进程".into() + } else { + format!("无法检查 ChatGPT 进程:{detail}") + }); + } + match String::from_utf8_lossy(&output.stdout).trim() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err("ChatGPT 进程检查结果无效".into()), + } } #[cfg(target_os = "macos")] @@ -451,8 +590,66 @@ pub fn target_is_running(_target: &TargetView) -> Result { Ok(false) } +#[cfg(any(target_os = "windows", test))] +const WINDOWS_GRACEFUL_STOP_SCRIPT: &str = r#" +$ErrorActionPreference = "Stop" +$matches = @(Get-Process | ForEach-Object { + try { $path = $_.Path } catch { $path = $null } + if ($path -and + $path -ieq $env:ACT_EXECUTABLE -and + $path -like ("*\" + $env:ACT_PACKAGE + "\*")) { + $_ + } +}) +if ($matches.Count -eq 0) { "not-running"; exit 0 } +foreach ($match in $matches) { + [void]$match.CloseMainWindow() +} +"requested" +"#; + +#[cfg(any(target_os = "windows", test))] +fn graceful_stop_was_requested(output: &str) -> bool { + matches!(output.trim(), "requested" | "not-running") +} + #[cfg(target_os = "windows")] pub fn stop_target(target: &TargetView) -> Result<(), String> { + let executable = target + .executable_path + .as_deref() + .ok_or("Windows 可执行文件身份缺失")?; + let package = target + .package_full_name + .as_deref() + .ok_or("Windows 包身份缺失")?; + let output = hidden_command("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + WINDOWS_GRACEFUL_STOP_SCRIPT, + ]) + .env("ACT_EXECUTABLE", executable) + .env("ACT_PACKAGE", package) + .output() + .map_err(|error| format!("无法关闭所选 ChatGPT:{error}"))?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + return Err(if detail.is_empty() { + "无法请求所选 ChatGPT 正常退出".into() + } else { + format!("无法请求所选 ChatGPT 正常退出:{detail}") + }); + } + if !graceful_stop_was_requested(&String::from_utf8_lossy(&output.stdout)) { + return Err("所选 ChatGPT 没有接受正常退出请求".into()); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +pub fn force_stop_target(target: &TargetView) -> Result<(), String> { let executable = target .executable_path .as_deref() @@ -474,16 +671,16 @@ $matches = @(Get-Process | ForEach-Object { foreach ($match in $matches) { Stop-Process -Id $match.Id -Force -ErrorAction Stop } -"true" +"stopped" "#; let output = hidden_command("powershell.exe") .args(["-NoProfile", "-NonInteractive", "-Command", script]) .env("ACT_EXECUTABLE", executable) .env("ACT_PACKAGE", package) .output() - .map_err(|error| format!("无法关闭所选 ChatGPT:{error}"))?; - if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim() != "true" { - return Err("无法安全关闭所选 ChatGPT".into()); + .map_err(|error| format!("无法强制关闭所选 ChatGPT:{error}"))?; + if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim() != "stopped" { + return Err("无法强制关闭所选 ChatGPT".into()); } Ok(()) } @@ -502,11 +699,21 @@ pub fn stop_target(target: &TargetView) -> Result<(), String> { Ok(()) } +#[cfg(target_os = "macos")] +pub fn force_stop_target(_target: &TargetView) -> Result<(), String> { + Err("请手动关闭 macOS ChatGPT;Theme Manager 不会强制终止它。".into()) +} + #[cfg(not(any(target_os = "windows", target_os = "macos")))] pub fn stop_target(_target: &TargetView) -> Result<(), String> { Err("当前平台暂不支持受控重启".into()) } +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +pub fn force_stop_target(_target: &TargetView) -> Result<(), String> { + Err("当前平台暂不支持强制关闭 ChatGPT".into()) +} + #[cfg(any(target_os = "windows", test))] fn windows_listener_owner_pid(output: &str, port: u16) -> Option { output.lines().find_map(|line| { @@ -699,8 +906,29 @@ fn macos_process_is_target_or_descendant(pid: &str, executable: &str) -> Result< } #[cfg(any(target_os = "macos", test))] -fn persistence_host_path_is_durable(path: &str) -> bool { - !path.starts_with("/Volumes/") && path.contains(".app/Contents/MacOS/") +fn persistence_host_path_is_durable(path: &str, home: &str) -> bool { + let path = std::path::Path::new(path); + [ + std::path::PathBuf::from("/Applications"), + std::path::Path::new(home).join("Applications"), + ] + .iter() + .any(|root| { + let Ok(relative) = path.strip_prefix(root) else { + return false; + }; + let mut parts = relative.components(); + let Some(std::path::Component::Normal(bundle)) = parts.next() else { + return false; + }; + if !bundle.to_string_lossy().ends_with(".app") + || !matches!(parts.next(), Some(std::path::Component::Normal(part)) if part == std::ffi::OsStr::new("Contents")) + || !matches!(parts.next(), Some(std::path::Component::Normal(part)) if part == std::ffi::OsStr::new("MacOS")) + { + return false; + } + matches!(parts.next(), Some(std::path::Component::Normal(_))) && parts.next().is_none() + }) } #[cfg(target_os = "macos")] @@ -709,7 +937,8 @@ pub fn validate_persistence_host() -> Result<(), String> { .and_then(std::fs::canonicalize) .map_err(|error| format!("无法定位 Theme Manager 安装路径:{error}"))?; let path = executable.to_string_lossy(); - if !persistence_host_path_is_durable(&path) { + let home = std::env::var("HOME").map_err(|error| format!("无法定位当前用户主目录:{error}"))?; + if !persistence_host_path_is_durable(&path, &home) { return Err("请先把 Awesome Codex Theme 拖入 Applications,再开启“始终应用”。".into()); } Ok(()) @@ -733,16 +962,88 @@ mod tests { fn macos_persistence_rejects_transient_disk_images() { assert!(!persistence_host_path_is_durable( "/Volumes/Awesome Codex Theme/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", )); assert!(persistence_host_path_is_durable( "/Applications/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", )); assert!(persistence_host_path_is_durable( "/Users/test/Applications/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", )); assert!(!persistence_host_path_is_durable( - "/usr/local/bin/awesome-codex-theme" + "/usr/local/bin/awesome-codex-theme", + "/Users/test", )); + assert!(!persistence_host_path_is_durable( + "/tmp/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", + )); + assert!(!persistence_host_path_is_durable( + "/Users/other/Applications/Awesome Codex Theme.app/Contents/MacOS/awesome-codex-theme", + "/Users/test", + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_graceful_stop_never_forces_termination() { + assert!(WINDOWS_GRACEFUL_STOP_SCRIPT.contains("CloseMainWindow")); + assert!(!WINDOWS_GRACEFUL_STOP_SCRIPT.contains("-Force")); + } + + #[cfg(target_os = "windows")] + #[test] + fn malformed_windows_discovery_is_a_diagnostic() { + let diagnostic = parse_windows_targets("not json").expect_err("invalid JSON should fail"); + assert_eq!(diagnostic.code, "malformed-output"); + } + + #[test] + fn discovery_error_codes_preserve_command_and_permission_failures() { + assert_eq!( + discovery_error_code(&std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing" + )), + "command-unavailable" + ); + assert_eq!( + discovery_error_code(&std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "denied", + )), + "permission-denied" + ); + assert_eq!( + discovery_output_error_code("Access is denied"), + "permission-denied" + ); + assert_eq!( + discovery_output_error_code("ACT_MALFORMED_PACKAGE: invalid metadata"), + "malformed-package" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_discovery_keeps_multiple_targets() { + let targets = parse_windows_targets( + r#"[{"channel":"stable","label":"ChatGPT","version":"1"},{"channel":"beta","label":"ChatGPT Beta","version":"2"}]"#, + ) + .expect("two valid targets should parse"); + assert_eq!(targets.len(), 2); + assert_eq!(targets[0].channel, "stable"); + assert_eq!(targets[1].channel, "beta"); + } + + #[cfg(target_os = "windows")] + #[test] + fn graceful_stop_accepts_a_normal_close_or_already_stopped_target() { + assert!(graceful_stop_was_requested("requested")); + assert!(graceful_stop_was_requested("not-running")); + assert!(!graceful_stop_was_requested("stopped")); } #[test] diff --git a/apps/theme-manager/src/renderer/app.js b/apps/theme-manager/src/renderer/app.js index 917c80d..46cea2a 100644 --- a/apps/theme-manager/src/renderer/app.js +++ b/apps/theme-manager/src/renderer/app.js @@ -63,16 +63,32 @@ const translations = { persistenceRetrying: "Retrying after a safe failure", persistenceBlocked: "Paused · reapply to verify this version", persistenceError: "Paused after a bounded failure", + persistenceNeedsUserClose: "Waiting for you to close the selected ChatGPT safely", + persistenceForceClosing: "Closing the selected ChatGPT after your confirmation", persistenceOther: "Another theme is kept: {theme} · {mode}", persistenceConsent: "Keep this theme after every future launch of the selected ChatGPT version once it completes a local verification?\n\nThis is the normal result of Apply & Keep Full Skin. Theme Manager will start for your user account at sign-in. When you open ChatGPT normally, it may close and relaunch only that exact app once with a loopback-only debugging port. A detected Stable or Beta build stays native until you explicitly choose Apply & Keep Full Skin to run a controlled local probe. Only a successful app-target and runtime-marker readback enables future replay for that exact version. It never edits ChatGPT files, shortcuts, or chats. You can turn this off here at any time.", consentEyebrow: "APPLY & KEEP", consentTitle: "Keep this Full Skin?", cancel: "Cancel", confirmApply: "Apply & Keep", + forceRestartEyebrow: "RESTART SAFETY", + forceRestartTitle: "Force close the selected ChatGPT?", + forceRestartWarning: "ChatGPT did not close after a normal request. Force closing may lose unsent prompts or other unsaved input. Cancel leaves the app and theme unchanged.", + forceRestartConfirm: "Force close & restart", + forceRestart: "Force close & restart", copyNative: "Copy Native palette only", skinIdle: "Theme packs contain only declarative data and images. The manager uses a temporary loopback-only debug session to load the background without modifying WindowsApps, app.asar, or ChatGPT private data.", skinActive: "Active in {target}: {theme} · {mode}. Restore the skin first; quit and reopen ChatGPT normally to close the debug port.", noTarget: "ChatGPT was not found", + retryTargetDiscovery: "Retry detection", + copyDiagnostic: "Copy diagnostic", + targetDiagnosticCommandUnavailable: "Target detection could not start.", + targetDiagnosticPermissionDenied: "Target detection was blocked by permissions.", + targetDiagnosticMalformedPackage: "Target detection found malformed package metadata.", + targetDiagnosticMalformedOutput: "Target detection returned invalid data.", + targetDiagnosticCommandFailed: "Target detection failed.", + targetDiagnosticUnsupported: "This platform does not support target detection.", + targetUnsupportedVersionError: "The selected ChatGPT version could not be verified.", onlineGallery: "Online Gallery", githubRepository: "GitHub repository", checkUpdate: "Check for app updates", @@ -100,6 +116,8 @@ const translations = { toastPersistenceEnabled: "Always apply is on for this theme.", toastPersistenceDisabled: "Always apply is off and the login task was removed.", toastPersistenceFailed: "Could not change the always-apply setting.", + toastDiagnosticCopied: "Diagnostic copied.", + toastForceRestartRequested: "Force close requested. The controller will reapply after ChatGPT exits.", toastUpdateFailed: "App update check failed.", startupFailed: "Theme Manager could not start.", closeCodexError: "Quit the selected ChatGPT app completely, then apply the theme again.", @@ -173,16 +191,32 @@ const translations = { persistenceRetrying: "遇到错误,正在安全重试", persistenceBlocked: "已暂停,请重新应用以验证当前版本", persistenceError: "有限重试失败,已暂停", + persistenceNeedsUserClose: "正在等待你安全关闭所选 ChatGPT", + persistenceForceClosing: "正按你的确认关闭所选 ChatGPT", persistenceOther: "当前保持的是:{theme} · {mode}", persistenceConsent: "要让这套主题在所选 ChatGPT 版本完成本机验证后,于以后每次打开时继续生效吗?\n\n这也是“应用并保持完整皮肤”的默认结果。Theme Manager 会为当前用户注册登录启动项。当你正常打开 ChatGPT 时,控制器可能先关闭并只重开这一个准确应用一次,再通过仅限本机回环的调试端口加载主题。检测到的 Stable/Beta 版本会先保持原生;只有你明确选择“应用并保持完整皮肤”时,才会进行一次受控本机探测。只有端口、页面和运行时标记读回成功后,才会在以后自动重放该准确版本。它不会修改 ChatGPT 文件、快捷方式或聊天。你可以随时在这里关闭。", consentEyebrow: "应用并保持", consentTitle: "保持使用这套完整皮肤?", cancel: "取消", confirmApply: "应用并保持", + forceRestartEyebrow: "重启安全", + forceRestartTitle: "强制关闭所选 ChatGPT?", + forceRestartWarning: "ChatGPT 在正常退出请求后仍未关闭。强制关闭可能丢失未发送的提示词或其他未保存输入。取消不会改变应用或主题状态。", + forceRestartConfirm: "强制关闭并重新应用", + forceRestart: "强制关闭并重新应用", copyNative: "只复制原生配色", skinIdle: "主题包只含声明式配置与图片。管理器通过仅限本机回环的临时调试会话加载背景,不修改 WindowsApps、app.asar 或 ChatGPT 私有数据。", skinActive: "正在 {target} 使用 {theme} · {mode}。恢复后如需关闭调试端口,请退出并正常重开 ChatGPT。", noTarget: "未检测到 ChatGPT", + retryTargetDiscovery: "重新检测", + copyDiagnostic: "复制诊断", + targetDiagnosticCommandUnavailable: "无法启动目标检测。", + targetDiagnosticPermissionDenied: "目标检测被权限阻止。", + targetDiagnosticMalformedPackage: "目标检测发现了损坏的软件包元数据。", + targetDiagnosticMalformedOutput: "目标检测返回了无效数据。", + targetDiagnosticCommandFailed: "目标检测失败。", + targetDiagnosticUnsupported: "当前平台不支持目标检测。", + targetUnsupportedVersionError: "无法验证所选 ChatGPT 的准确版本。", onlineGallery: "在线 Gallery", githubRepository: "GitHub 仓库", checkUpdate: "检查应用更新", @@ -210,6 +244,8 @@ const translations = { toastPersistenceEnabled: "已为这套主题开启始终应用。", toastPersistenceDisabled: "已关闭始终应用并移除登录启动项。", toastPersistenceFailed: "无法更改始终应用设置。", + toastDiagnosticCopied: "诊断信息已复制。", + toastForceRestartRequested: "已请求强制关闭;ChatGPT 退出后控制器会重新应用。", toastUpdateFailed: "应用更新检查失败。", startupFailed: "无法启动主题管理器。", closeCodexError: "请先完全退出所选 ChatGPT,再重新应用主题。", @@ -245,6 +281,7 @@ const state = { catalog: null, catalogState: null, targets: [], + targetDiagnostic: null, skinState: null, persistenceState: null, updateState: null, @@ -290,6 +327,9 @@ const elements = { copyShortcut: document.querySelector("#copyShortcut"), targetSelect: document.querySelector("#targetSelect"), restoreSkin: document.querySelector("#restoreSkin"), + targetDiagnosticActions: document.querySelector("#targetDiagnosticActions"), + retryTargetDiscovery: document.querySelector("#retryTargetDiscovery"), + copyTargetDiagnostic: document.querySelector("#copyTargetDiagnostic"), persistenceControl: document.querySelector("#persistenceControl"), persistenceToggle: document.querySelector("#persistenceToggle"), persistenceStatus: document.querySelector("#persistenceStatus"), @@ -304,10 +344,15 @@ const elements = { persistenceConsentDialog: document.querySelector("#persistenceConsentDialog"), persistenceConsentCancel: document.querySelector("#persistenceConsentCancel"), persistenceConsentConfirm: document.querySelector("#persistenceConsentConfirm"), + forceRestart: document.querySelector("#forceRestart"), + forceRestartDialog: document.querySelector("#forceRestartDialog"), + forceRestartCancel: document.querySelector("#forceRestartCancel"), + forceRestartConfirm: document.querySelector("#forceRestartConfirm"), toast: document.querySelector("#toast"), }; let consentResolver = null; +let forceRestartResolver = null; function finishPersistenceConsent(confirmed) { if (!consentResolver) return; @@ -326,6 +371,23 @@ function requestPersistenceConsent() { }); } +function finishForceRestartConsent(confirmed) { + if (!forceRestartResolver) return; + const resolve = forceRestartResolver; + forceRestartResolver = null; + elements.forceRestartDialog.hidden = true; + resolve(confirmed); +} + +function requestForceRestartConsent() { + if (forceRestartResolver) return Promise.resolve(false); + elements.forceRestartDialog.hidden = false; + elements.forceRestartConfirm.focus(); + return new Promise((resolve) => { + forceRestartResolver = resolve; + }); +} + function localized(value) { return value?.[state.locale] || value?.en || value?.["zh-CN"] || ""; } @@ -338,6 +400,7 @@ function friendlyError(error, fallbackKey) { return reason ? `${t(key)}\n${t("errorReason")}: ${reason}` : t(key); }; if (/完全退出|正在普通模式运行|already running/i.test(raw)) return withReason("closeCodexError"); + if (/\[unsupported-version\]/i.test(raw)) return withReason("targetUnsupportedVersionError"); if (/没有检测到|not found/i.test(raw)) return withReason("targetMissingError"); if (/素材|哈希|PNG|integrity/i.test(raw)) return withReason("assetError"); if (/CDP|调试|Full Skin 会话|loopback|LaunchServices/i.test(raw)) return withReason("sessionError"); @@ -636,12 +699,57 @@ function renderSelectedTheme() { renderPersistenceState(); } +function targetDiagnosticLabel(diagnostic) { + const labels = { + "command-unavailable": "targetDiagnosticCommandUnavailable", + "permission-denied": "targetDiagnosticPermissionDenied", + "malformed-package": "targetDiagnosticMalformedPackage", + "malformed-output": "targetDiagnosticMalformedOutput", + "command-failed": "targetDiagnosticCommandFailed", + "unsupported-platform": "targetDiagnosticUnsupported", + }; + return t(labels[diagnostic?.code] || "targetDiagnosticCommandFailed"); +} + +function targetDiagnosticText() { + const diagnostic = state.targetDiagnostic; + if (!diagnostic) return ""; + const detail = String(diagnostic.detail || "").replace(/\s+/gu, " ").trim(); + return `${targetDiagnosticLabel(diagnostic)}\nCode: ${diagnostic.code || "command-failed"}${detail ? `\n${detail}` : ""}`; +} + +async function refreshTargets() { + const discovery = await window.act.discoverTargets(); + state.targets = discovery.targets || []; + state.targetDiagnostic = discovery.diagnostic || null; + renderTargets(); +} + +async function copyPlainText(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.append(input); + input.select(); + const copied = document.execCommand("copy"); + input.remove(); + if (!copied) throw new Error("Clipboard is unavailable"); +} + function renderTargets() { const fragment = document.createDocumentFragment(); if (!state.targets.length) { const option = document.createElement("option"); option.value = ""; - option.textContent = t("noTarget"); + option.textContent = state.targetDiagnostic + ? targetDiagnosticLabel(state.targetDiagnostic) + : t("noTarget"); fragment.append(option); } else { state.targets.forEach((target) => { @@ -652,6 +760,7 @@ function renderTargets() { }); } elements.targetSelect.replaceChildren(fragment); + elements.targetDiagnosticActions.hidden = !state.targetDiagnostic; elements.applySkin.disabled = state.targets.length === 0 || !state.themeId; renderSkinState(); renderPersistenceState(); @@ -669,13 +778,16 @@ function renderSkinState() { elements.applySkinLabel.textContent = sameSelection ? t("reapplySkin") : t("applySkin"); elements.restoreSkin.disabled = !skin.active && !state.persistenceState?.enabled; const active = sameSessionSelection ? skin : persistentActive ? state.persistenceState : null; - elements.skinStatus.textContent = active + const status = active ? t("skinActive", { target: active.channel === "beta" ? "ChatGPT Beta" : "ChatGPT", theme: active.themeId, mode: t(active.mode), }) : t("skinIdle"); + elements.skinStatus.textContent = state.targetDiagnostic && !state.targets.length + ? targetDiagnosticText() + : status; } function persistenceMatchesSelection(persistence, channel = elements.targetSelect.value) { @@ -714,10 +826,12 @@ function renderPersistenceState() { "version-blocked": "persistenceBlocked", "retry-blocked": "persistenceError", error: "persistenceError", + "needs-user-close": "persistenceNeedsUserClose", + "force-closing": "persistenceForceClosing", }; const label = t(labels[persistence.phase] || "persistenceDisabled"); elements.persistenceStatus.textContent = persistence.detail - && ["blocked", "target-missing", "version-blocked", "retrying", "retry-blocked", "error"].includes(persistence.phase) + && ["blocked", "target-missing", "version-blocked", "retrying", "retry-blocked", "error", "needs-user-close", "force-closing"].includes(persistence.phase) ? `${label} · ${persistence.detail}` : label; } @@ -725,6 +839,12 @@ function renderPersistenceState() { const busy = ["starting", "restarting"].includes(persistence.phase); elements.persistenceToggle.disabled = busy || (!sameSelection && (state.targets.length === 0 || !state.themeId || !channel)); + const forceRestartAvailable = state.platform === "win32" + && persistence.enabled + && persistence.channel === channel + && ["needs-user-close", "force-closing"].includes(persistence.phase); + elements.forceRestart.hidden = !forceRestartAvailable; + elements.forceRestart.disabled = !forceRestartAvailable; elements.restoreSkin.disabled = !(state.skinState?.active || persistence.enabled); } @@ -919,6 +1039,28 @@ elements.refreshCatalog.addEventListener("click", async () => { } }); +elements.retryTargetDiscovery.addEventListener("click", async () => { + elements.retryTargetDiscovery.disabled = true; + try { + await refreshTargets(); + } catch (error) { + toast(friendlyError(error, "targetDiagnosticCommandFailed"), "error"); + } finally { + elements.retryTargetDiscovery.disabled = false; + } +}); + +elements.copyTargetDiagnostic.addEventListener("click", async () => { + const text = targetDiagnosticText(); + if (!text) return; + try { + await copyPlainText(text); + toast(t("toastDiagnosticCopied")); + } catch { + toast(t("toastCopyFailed"), "error"); + } +}); + elements.copyTheme.addEventListener("click", async () => { try { await window.act.copyTheme(state.themeId, state.mode); @@ -928,6 +1070,21 @@ elements.copyTheme.addEventListener("click", async () => { } }); +elements.forceRestart.addEventListener("click", async () => { + const channel = elements.targetSelect.value; + if (!channel || !await requestForceRestartConsent()) return; + elements.forceRestart.disabled = true; + try { + state.persistenceState = await window.act.forceRestartPersistentTheme(channel, true); + renderPersistenceState(); + toast(t("toastForceRestartRequested")); + } catch (error) { + toast(friendlyError(error, "toastPersistenceFailed"), "error"); + } finally { + renderPersistenceState(); + } +}); + elements.applySkin.addEventListener("click", async () => { const channel = elements.targetSelect.value; if (!channel || !state.themeId) return; @@ -1014,11 +1171,20 @@ elements.persistenceConsentConfirm.addEventListener("click", () => finishPersist elements.persistenceConsentDialog.addEventListener("click", (event) => { if (event.target === elements.persistenceConsentDialog) finishPersistenceConsent(false); }); +elements.forceRestartCancel.addEventListener("click", () => finishForceRestartConsent(false)); +elements.forceRestartConfirm.addEventListener("click", () => finishForceRestartConsent(true)); +elements.forceRestartDialog.addEventListener("click", (event) => { + if (event.target === elements.forceRestartDialog) finishForceRestartConsent(false); +}); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !elements.persistenceConsentDialog.hidden) { event.preventDefault(); finishPersistenceConsent(false); } + if (event.key === "Escape" && !elements.forceRestartDialog.hidden) { + event.preventDefault(); + finishForceRestartConsent(false); + } }); elements.openGallery.addEventListener("click", () => window.act.openExternal("gallery")); @@ -1067,6 +1233,7 @@ try { state.platform = bootstrap.platform; renderWindowChrome(); state.targets = bootstrap.targets; + state.targetDiagnostic = bootstrap.targetDiagnostic || null; state.skinState = bootstrap.skinState; state.persistenceState = bootstrap.persistenceState; state.updateState = bootstrap.updateState; diff --git a/apps/theme-manager/src/renderer/bridge.js b/apps/theme-manager/src/renderer/bridge.js index f3dab95..4d94200 100644 --- a/apps/theme-manager/src/renderer/bridge.js +++ b/apps/theme-manager/src/renderer/bridge.js @@ -39,6 +39,7 @@ function subscribe(channel, callback) { window.act = Object.freeze({ bootstrap: async () => normalizeCatalogPayload(await invoke("bootstrap")), + discoverTargets: () => invoke("discover_targets"), refreshCatalog: async () => normalizeCatalogPayload(await invoke("refresh_catalog")), copyTheme: (themeId, mode) => invoke("copy_theme", { themeId, mode }), openCodex: (channel) => invoke("open_codex", { channel }), @@ -49,6 +50,8 @@ window.act = Object.freeze({ enablePersistentTheme: (themeId, mode, channel, consent, applyNow = false) => invoke("enable_persistent_theme", { themeId, mode, channel, consent, applyNow }), disablePersistentTheme: () => invoke("disable_persistent_theme"), + forceRestartPersistentTheme: (channel, confirmed) => + invoke("force_restart_persistent_theme", { channel, confirmed }), openExternal: (target) => invoke("open_external", { target }), checkForAppUpdate: () => invoke("check_for_app_update"), installAppUpdate: () => invoke("install_app_update"), diff --git a/apps/theme-manager/src/renderer/index.html b/apps/theme-manager/src/renderer/index.html index 7001fdb..033cf22 100644 --- a/apps/theme-manager/src/renderer/index.html +++ b/apps/theme-manager/src/renderer/index.html @@ -154,6 +154,10 @@

+ + @@ -222,6 +227,21 @@

Keep this Full Skin? + + diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 035473d..a2b9229 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Preserved target-discovery diagnostics instead of treating PowerShell, + permission, malformed-package, or malformed-output failures as “not found”. + The Theme Manager now provides retry and copy-diagnostic controls. +- Changed Windows persistence restarts to request a normal app close first. + When that bounded wait expires, the controller leaves the existing selection + unchanged and requires a second, explicit data-loss warning before a force + close can be requested. macOS persistence hosts are now limited to + `/Applications` or the current user's `Applications` folder. - Changed the Gallery community section to clearly state that public community submissions are not open and removed its hosted-community link. Added a direct Theme Manager download action to the Gallery hero. diff --git a/docs/architecture.md b/docs/architecture.md index 746f3c5..a1bbd0e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,10 +71,10 @@ Rust owns: - exact Stable/Beta discovery; - PNG download, cache, byte-count, signature, and SHA-256 checks; - loopback CDP session startup; -- target and WebSocket validation; +- target and WebSocket validation, including structured discovery diagnostics; - early and current-page injection; - restore state; - - consent-gated per-user persistence state, autostart, version gates, and bounded replay; + - consent-gated per-user persistence state, autostart, version gates, bounded replay, and a normal-close-first restart path; a Windows force close is available only after the manager shows a second data-loss warning; - Native fallback copy; - release-update state.