From 91582cfa225f9a3e3b8d9dd8cfde492c7ecca350 Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Sat, 22 Aug 2026 22:31:34 +0800 Subject: [PATCH] feat(manager): support pending settings navigation --- apps/codex-plus-launcher/src/main.rs | 39 +++-- .../src-tauri/src/commands.rs | 7 + apps/codex-plus-manager/src-tauri/src/lib.rs | 8 +- apps/codex-plus-manager/src/App.tsx | 65 +++++++- crates/codex-plus-core/src/install/mod.rs | 18 ++ crates/codex-plus-core/src/lib.rs | 1 + .../codex-plus-core/src/manager_navigation.rs | 156 ++++++++++++++++++ crates/codex-plus-core/src/paths.rs | 11 ++ crates/codex-plus-core/src/routes.rs | 46 ++++-- crates/codex-plus-core/tests/bridge_routes.rs | 26 ++- 10 files changed, 345 insertions(+), 32 deletions(-) create mode 100644 crates/codex-plus-core/src/manager_navigation.rs diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index 800befaa3..78d1240da 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -859,27 +859,46 @@ impl BridgeRuntimeService for LauncherRuntimeService { })) } - async fn open_manager(&self) -> anyhow::Result { - let target = codex_plus_core::install::spawn_companion( - codex_plus_core::install::MANAGER_BINARY, - std::iter::empty::<&str>(), - ) - .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}"))?; + async fn open_manager(&self, payload: Value) -> anyhow::Result { + let navigation = + codex_plus_core::manager_navigation::save_pending_manager_navigation_from_payload( + &payload, + )?; + let target = codex_plus_core::install::open_or_activate_manager() + .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}")) + .map_err(|error| { + codex_plus_core::manager_navigation::rollback_pending_manager_navigation_after_launch_failure( + navigation.as_ref(), + error, + ) + })?; Ok(json!({ "status": "ok", - "path": target + "path": target, + "navigation": navigation })) } - async fn open_transient_manager(&self) -> anyhow::Result { + async fn open_transient_manager(&self, payload: Value) -> anyhow::Result { + let navigation = + codex_plus_core::manager_navigation::save_pending_manager_navigation_from_payload( + &payload, + )?; let target = codex_plus_core::install::spawn_companion( codex_plus_core::install::MANAGER_BINARY, ["--transient"], ) - .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}"))?; + .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}")) + .map_err(|error| { + codex_plus_core::manager_navigation::rollback_pending_manager_navigation_after_launch_failure( + navigation.as_ref(), + error, + ) + })?; Ok(json!({ "status": "ok", - "path": target + "path": target, + "navigation": navigation })) } diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 0d0401f22..d3e36a0ec 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -493,6 +493,13 @@ pub fn startup_options() -> CommandResult { ) } +#[tauri::command] +pub fn consume_pending_manager_navigation( +) -> Result, String> { + codex_plus_core::manager_navigation::consume_pending_manager_navigation() + .map_err(|error| error.to_string()) +} + pub fn startup_should_show_update() -> bool { should_show_update( std::env::args(), diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index 04efd9a51..2612e22e2 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use tauri::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; -use tauri::{Manager, WindowEvent}; +use tauri::{Emitter, Manager, WindowEvent}; const TRAY_ID: &str = "codex_plus_tray"; @@ -14,6 +14,7 @@ const TRAY_MENU_SHOW: &str = "tray_show_main"; const TRAY_MENU_DREAM_SKIN_APPLY: &str = "tray_apply_dream_skin"; const TRAY_MENU_QUIT: &str = "tray_quit_app"; const DREAM_SKIN_DEBUG_PORT: u16 = 9229; +const MANAGER_NAVIGATION_EVENT: &str = "manager-navigation-requested"; pub fn run() { install_panic_logger(); @@ -65,6 +66,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::backend_version, commands::startup_options, + commands::consume_pending_manager_navigation, commands::load_overview, commands::launch_codex_plus, commands::restart_codex_plus, @@ -271,6 +273,7 @@ fn register_main_window_events( let minimized_window = event_window.clone(); let close_event_window = event_window.clone(); let close_event_app = event_window.app_handle().clone(); + let focus_event_window = event_window.clone(); event_window.on_window_event(move |event| match event { WindowEvent::Resized(_) => { @@ -278,6 +281,9 @@ fn register_main_window_events( let _ = minimized_window.hide(); } } + WindowEvent::Focused(true) => { + let _ = focus_event_window.emit(MANAGER_NAVIGATION_EVENT, ()); + } WindowEvent::CloseRequested { api, .. } => { if APP_EXITING.load(Ordering::SeqCst) { return; diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index afbb4e0fb..58e4e71b6 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -827,9 +827,17 @@ type StartupResult = CommandResult<{ showUpdate: boolean; }>; +type ManagerNavigationIntent = { + page: "settings"; + section?: "stepwise"; +}; + type Route = "overview" | "relay" | "relayEnvironment" | "sessions" | "context" | "weixin" | "enhance" | "dreamSkin" | "zedRemote" | "userScripts" | "recommendations" | "maintenance" | "about" | "settings"; type Theme = "dark" | "light"; +const MANAGER_NAVIGATION_EVENT = "manager-navigation-requested"; +const SETTINGS_STEPWISE_SECTION_ID = "settings-stepwise"; + const routes: Array<{ id: Route; label: string; icon: LucideIcon; badge?: string }> = [ { id: "overview", label: t("概览"), icon: LayoutDashboard }, { id: "relay", label: t("供应商配置"), icon: KeyRound }, @@ -966,6 +974,7 @@ const defaultSettings: BackendSettings = { export function App() { const [theme, setTheme] = useState(() => loadInitialTheme()); const [route, setRoute] = useState(() => loadInitialRoute()); + const [pendingSettingsSection, setPendingSettingsSection] = useState(null); const [notice, setNotice] = useState<{ title: string; message: string; status?: Status } | null>(null); const [confirmDialog, setConfirmDialog] = useState<{ title: string; @@ -2700,6 +2709,22 @@ export function App() { await call("manager_hide_to_tray"); }; + const consumePendingManagerNavigation = async (): Promise => { + try { + const navigation = await invoke("consume_pending_manager_navigation"); + if (!navigation) return false; + if (navigation.page === "settings") { + setPendingSettingsSection(navigation.section ?? null); + setRoute("settings"); + await refreshSettings(true); + return true; + } + } catch (error) { + logDiagnostic("manager.navigation_failed", { error: stringifyError(error) }); + } + return false; + }; + const showResultNotice = ( title: string, result: Pick, "message" | "status">, @@ -2712,14 +2737,15 @@ export function App() { useEffect(() => { void (async () => { const startup = await run(() => call("startup_options")); - if (startup?.showUpdate) { + const handledNavigation = await consumePendingManagerNavigation(); + if (!handledNavigation && startup?.showUpdate) { setRoute("about"); void checkUpdate(false); } else { void checkUpdate(true); } await refreshOverview(true); - await refreshSettings(true); + if (!handledNavigation) await refreshSettings(true); await refreshRelay(true); await refreshEnvConflicts(true); await refreshProviderSyncTargets(true); @@ -2729,6 +2755,39 @@ export function App() { })(); }, []); + useEffect(() => { + let disposed = false; + let stopListening: (() => void) | undefined; + void listen(MANAGER_NAVIGATION_EVENT, () => { + if (!disposed) void consumePendingManagerNavigation(); + }).then((unlisten) => { + if (disposed) unlisten(); + else stopListening = unlisten; + }); + return () => { + disposed = true; + stopListening?.(); + }; + }, []); + + useEffect(() => { + if (route !== "settings" || pendingSettingsSection !== "stepwise") return; + let secondFrame = 0; + const firstFrame = window.requestAnimationFrame(() => { + secondFrame = window.requestAnimationFrame(() => { + document.getElementById(SETTINGS_STEPWISE_SECTION_ID)?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + setPendingSettingsSection(null); + }); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + if (secondFrame) window.cancelAnimationFrame(secondFrame); + }; + }, [pendingSettingsSection, route]); + useEffect(() => { if (getLanguage() === "en") { void invoke("update_tray_labels", { @@ -6204,7 +6263,7 @@ function SettingsScreen({ placeholder={t("例如 gpt-5.4-mini")} /> -
+
Stepwise
{t("连接")}
diff --git a/crates/codex-plus-core/src/install/mod.rs b/crates/codex-plus-core/src/install/mod.rs index 40cc46c95..53828d8ec 100644 --- a/crates/codex-plus-core/src/install/mod.rs +++ b/crates/codex-plus-core/src/install/mod.rs @@ -293,6 +293,24 @@ where Ok(path.to_string_lossy().to_string()) } +pub fn open_or_activate_manager() -> anyhow::Result { + #[cfg(target_os = "macos")] + { + let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from(".")); + if let Some(bundle_id) = macos_companion_bundle_identifier_from_exe(&exe, MANAGER_BINARY) { + let activated = Command::new("/usr/bin/open") + .args(["-b", bundle_id]) + .status() + .is_ok_and(|status| status.success()); + if activated { + return Ok(format!("bundle:{bundle_id}")); + } + } + } + + spawn_companion(MANAGER_BINARY, std::iter::empty::<&str>()) +} + pub fn macos_companion_bundle_identifier_from_exe( exe: &Path, binary: &str, diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index fd568567e..284040a66 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod env_conflicts; pub mod http_client; pub mod install; pub mod launcher; +pub mod manager_navigation; pub mod model_catalog; pub mod model_suffix; pub mod models; diff --git a/crates/codex-plus-core/src/manager_navigation.rs b/crates/codex-plus-core/src/manager_navigation.rs new file mode 100644 index 000000000..99305fde0 --- /dev/null +++ b/crates/codex-plus-core/src/manager_navigation.rs @@ -0,0 +1,156 @@ +use anyhow::Context; +use serde_json::Value; +use std::path::Path; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManagerNavigationIntent { + pub page: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub section: Option, +} + +pub fn save_pending_manager_navigation_from_payload( + payload: &Value, +) -> anyhow::Result> { + if payload.as_object().is_some_and(|object| object.is_empty()) { + return Ok(None); + } + let navigation: ManagerNavigationIntent = + serde_json::from_value(payload.clone()).context("管理工具导航参数无效")?; + validate(&navigation)?; + save_pending_manager_navigation(&navigation)?; + Ok(Some(navigation)) +} + +pub fn save_pending_manager_navigation(navigation: &ManagerNavigationIntent) -> anyhow::Result<()> { + save_pending_manager_navigation_at( + &crate::paths::default_pending_manager_navigation_path(), + navigation, + ) +} + +pub fn consume_pending_manager_navigation() -> anyhow::Result> { + consume_pending_manager_navigation_at(&crate::paths::default_pending_manager_navigation_path()) +} + +pub fn rollback_pending_manager_navigation_after_launch_failure( + navigation: Option<&ManagerNavigationIntent>, + launch_error: anyhow::Error, +) -> anyhow::Error { + let Some(navigation) = navigation else { + return launch_error; + }; + match remove_pending_manager_navigation_if_matches(navigation) { + Ok(_) => launch_error, + Err(error) => launch_error.context(format!("清理未完成的管理工具导航失败:{error}")), + } +} + +pub fn save_pending_manager_navigation_at( + path: &Path, + navigation: &ManagerNavigationIntent, +) -> anyhow::Result<()> { + validate(navigation)?; + let contents = format!("{}\n", serde_json::to_string_pretty(navigation)?); + crate::settings::atomic_write(path, contents.as_bytes()) + .with_context(|| format!("保存管理工具导航失败:{}", path.display())) +} + +pub fn consume_pending_manager_navigation_at( + path: &Path, +) -> anyhow::Result> { + let contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).with_context(|| format!("读取管理工具导航失败:{}", path.display())), + }; + let navigation = serde_json::from_str(&contents).context("管理工具导航内容无效")?; + validate(&navigation)?; + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).with_context(|| format!("清理管理工具导航失败:{}", path.display())), + } + Ok(Some(navigation)) +} + +fn remove_pending_manager_navigation_if_matches( + navigation: &ManagerNavigationIntent, +) -> anyhow::Result { + let path = crate::paths::default_pending_manager_navigation_path(); + let contents = match std::fs::read_to_string(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error).with_context(|| format!("读取管理工具导航失败:{}", path.display())), + }; + let pending: ManagerNavigationIntent = + serde_json::from_str(&contents).context("管理工具导航内容无效")?; + if pending != *navigation { + return Ok(false); + } + match std::fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).context("清理管理工具导航失败"), + } +} + +fn validate(navigation: &ManagerNavigationIntent) -> anyhow::Result<()> { + match (navigation.page.as_str(), navigation.section.as_deref()) { + ("settings", None | Some("stepwise")) => Ok(()), + _ => anyhow::bail!( + "不支持的管理工具导航:{}/{}", + navigation.page, + navigation.section.as_deref().unwrap_or("") + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saves_and_consumes_navigation_once() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("pending-manager-navigation.json"); + let navigation = ManagerNavigationIntent { + page: "settings".to_string(), + section: Some("stepwise".to_string()), + }; + + save_pending_manager_navigation_at(&path, &navigation).unwrap(); + assert_eq!(consume_pending_manager_navigation_at(&path).unwrap(), Some(navigation)); + assert_eq!(consume_pending_manager_navigation_at(&path).unwrap(), None); + } + + #[test] + fn rejects_unknown_navigation_targets() { + let error = save_pending_manager_navigation_from_payload(&serde_json::json!({ + "page": "settings", + "section": "unknown" + })) + .unwrap_err(); + assert!(error.to_string().contains("不支持的管理工具导航")); + } + + #[test] + fn launch_cleanup_does_not_remove_replacement_navigation() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("pending-manager-navigation.json"); + let failed = ManagerNavigationIntent { + page: "settings".to_string(), + section: Some("stepwise".to_string()), + }; + let replacement = ManagerNavigationIntent { + page: "settings".to_string(), + section: None, + }; + + save_pending_manager_navigation_at(&path, &failed).unwrap(); + save_pending_manager_navigation_at(&path, &replacement).unwrap(); + let loaded = consume_pending_manager_navigation_at(&path).unwrap(); + assert_eq!(loaded, Some(replacement)); + } +} diff --git a/crates/codex-plus-core/src/paths.rs b/crates/codex-plus-core/src/paths.rs index a666794be..0697b64a3 100644 --- a/crates/codex-plus-core/src/paths.rs +++ b/crates/codex-plus-core/src/paths.rs @@ -7,6 +7,7 @@ const LATEST_STATUS_FILE: &str = "latest-status.json"; const DIAGNOSTIC_LOG_FILE: &str = "codex-plus.log"; const PENDING_PROVIDER_IMPORT_FILE: &str = "pending-provider-import.json"; const PENDING_REMOTE_CONTROL_RECOVERY_FILE: &str = "pending-remote-control-recovery.json"; +const PENDING_MANAGER_NAVIGATION_FILE: &str = "pending-manager-navigation.json"; pub fn default_app_state_dir() -> PathBuf { if let Some(home_dir) = directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()) { @@ -39,6 +40,10 @@ pub fn default_pending_remote_control_recovery_path() -> PathBuf { default_app_state_dir().join(PENDING_REMOTE_CONTROL_RECOVERY_FILE) } +pub fn default_pending_manager_navigation_path() -> PathBuf { + default_app_state_dir().join(PENDING_MANAGER_NAVIGATION_FILE) +} + fn settings_path_for_tests() -> Option { SETTINGS_PATH_FOR_TESTS .get_or_init(|| Mutex::new(None)) @@ -107,4 +112,10 @@ mod tests { assert!(path.ends_with(".codex-session-delete/pending-remote-control-recovery.json")); } + + #[test] + fn default_pending_manager_navigation_path_uses_app_state_directory() { + let path = default_pending_manager_navigation_path(); + assert!(path.ends_with(".codex-session-delete/pending-manager-navigation.json")); + } } diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index b9580114c..01a19f8d5 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -83,9 +83,9 @@ pub trait BridgeRuntimeService: Send + Sync { async fn delete_user_script(&self, key: String) -> anyhow::Result; async fn reload_user_scripts(&self) -> anyhow::Result; async fn open_devtools(&self) -> anyhow::Result; - async fn open_manager(&self) -> anyhow::Result; - async fn open_transient_manager(&self) -> anyhow::Result { - self.open_manager().await + async fn open_manager(&self, payload: Value) -> anyhow::Result; + async fn open_transient_manager(&self, payload: Value) -> anyhow::Result { + self.open_manager(payload).await } async fn backend_status(&self) -> anyhow::Result; async fn codex_model_catalog(&self) -> anyhow::Result; @@ -182,8 +182,8 @@ pub async fn handle_bridge_request( } "/user-scripts/reload" => ctx.runtime.reload_user_scripts().await, "/devtools/open" => ctx.runtime.open_devtools().await, - "/manager/open" => ctx.runtime.open_manager().await, - "/manager/open-transient" => ctx.runtime.open_transient_manager().await, + "/manager/open" => ctx.runtime.open_manager(payload.clone()).await, + "/manager/open-transient" => ctx.runtime.open_transient_manager(payload.clone()).await, "/backend/status" => backend_status_value( ctx.runtime.backend_status().await, ctx.settings.get_settings().await, @@ -459,23 +459,41 @@ impl BridgeRuntimeService for CoreRuntimeService { })) } - async fn open_manager(&self) -> anyhow::Result { - let target = crate::install::spawn_companion( - crate::install::MANAGER_BINARY, - std::iter::empty::<&str>(), + async fn open_manager(&self, payload: Value) -> anyhow::Result { + let navigation = crate::manager_navigation::save_pending_manager_navigation_from_payload( + &payload, )?; + let target = crate::install::open_or_activate_manager().map_err(|error| { + crate::manager_navigation::rollback_pending_manager_navigation_after_launch_failure( + navigation.as_ref(), + error, + ) + })?; Ok(json!({ "status": "ok", - "path": target + "path": target, + "navigation": navigation })) } - async fn open_transient_manager(&self) -> anyhow::Result { - let target = - crate::install::spawn_companion(crate::install::MANAGER_BINARY, ["--transient"])?; + async fn open_transient_manager(&self, payload: Value) -> anyhow::Result { + let navigation = crate::manager_navigation::save_pending_manager_navigation_from_payload( + &payload, + )?; + let target = crate::install::spawn_companion( + crate::install::MANAGER_BINARY, + ["--transient"], + ) + .map_err(|error| { + crate::manager_navigation::rollback_pending_manager_navigation_after_launch_failure( + navigation.as_ref(), + error, + ) + })?; Ok(json!({ "status": "ok", - "path": target + "path": target, + "navigation": navigation })) } diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index 4bc1976ce..c26489c57 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -460,16 +460,30 @@ async fn runtime_routes_keep_user_script_inventory_shape() { #[tokio::test] async fn runtime_status_devtools_repair_and_ads_routes_are_dispatched() { - let ctx = test_context(); + let runtime = Arc::new(FakeRuntime::default()); + let ctx = BridgeContext::new( + Arc::new(FakeSettings::default()), + runtime.clone(), + Arc::new(FakeData::default()), + ); assert_eq!( handle_bridge_request(ctx.clone(), "/devtools/open", json!({})).await, json!({"status": "ok", "opened": true}) ); assert_eq!( - handle_bridge_request(ctx.clone(), "/manager/open", json!({})).await, + handle_bridge_request( + ctx.clone(), + "/manager/open", + json!({"page": "settings", "section": "stepwise"}), + ) + .await, json!({"status": "ok", "opened": "manager"}) ); + assert_eq!( + *runtime.manager_payload.lock().unwrap(), + json!({"page": "settings", "section": "stepwise"}) + ); assert_eq!( handle_bridge_request(ctx.clone(), "/manager/open-transient", json!({})).await, json!({"status": "ok", "opened": "manager-transient"}) @@ -1204,6 +1218,7 @@ impl BridgeSettingsService for FakeSettings { struct FakeRuntime { enabled: Mutex, script_enabled: Mutex, + manager_payload: Mutex, } impl Default for FakeRuntime { @@ -1211,6 +1226,7 @@ impl Default for FakeRuntime { Self { enabled: Mutex::new(true), script_enabled: Mutex::new(true), + manager_payload: Mutex::new(json!({})), } } } @@ -1246,11 +1262,13 @@ impl BridgeRuntimeService for FakeRuntime { Ok(json!({"status": "ok", "opened": true})) } - async fn open_manager(&self) -> anyhow::Result { + async fn open_manager(&self, payload: Value) -> anyhow::Result { + *self.manager_payload.lock().unwrap() = payload; Ok(json!({"status": "ok", "opened": "manager"})) } - async fn open_transient_manager(&self) -> anyhow::Result { + async fn open_transient_manager(&self, payload: Value) -> anyhow::Result { + *self.manager_payload.lock().unwrap() = payload; Ok(json!({"status": "ok", "opened": "manager-transient"})) }