diff --git a/.gitignore b/.gitignore index 65156b4b1..54940a99e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ apps/codex-plus-manager/src-tauri/gen/ .superpowers/ .claude/ docs/superpowers/ +.codex-index/ +.context/ +.data/ +.idea/ *.log .DS_Store @@ -29,4 +33,6 @@ pnpm-workspace.yaml .trae/ plan.md plan_v2.md +apps/codex-taskboard/.codex-legacy-bridge/ +apps/codex-taskboard/*proof*.png diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index a6947b83e..f4df0831e 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -9,6 +9,7 @@ use codex_plus_core::routes::{BridgeContext, BridgeDataService, BridgeRuntimeSer use codex_plus_core::user_scripts::UserScriptManager; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; #[derive(Clone)] @@ -77,6 +78,9 @@ async fn launcher_main() -> Result<()> { }); let hooks = LauncherHooks::default(); let handle = launch_and_inject_with_hooks(options, &hooks).await?; + if let Ok(settings) = hooks.load_settings().await { + start_taskboard_sidebar_injector_if_enabled(&settings, handle.debug_port); + } handle.wait_for_codex_exit().await?; Ok(()) } @@ -197,6 +201,14 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< break; } } + if !activated && launch_result.is_ok() { + codex_plus_core::launcher::activate_codex_window_after_launch( + launch_result + .as_ref() + .ok() + .and_then(|launch| launch.process_id()), + ); + } } let injection_ready = if settings.enhancements_enabled { hooks @@ -206,6 +218,7 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result< false }; if injection_ready { + start_taskboard_sidebar_injector_if_enabled(&settings, options.debug_port); hooks .start_bridge_watchdog(options.debug_port, helper_port) .await?; @@ -266,6 +279,125 @@ fn open_manager_with_update_prompt() -> anyhow::Result<()> { .map_err(|error| anyhow::anyhow!("启动管理工具失败:{error}")) } +fn start_taskboard_sidebar_injector_if_enabled( + settings: &codex_plus_core::settings::BackendSettings, + debug_port: u16, +) { + if !settings.enhancements_enabled || !settings.codex_taskboard_enabled { + return; + } + match spawn_taskboard_sidebar_injector(debug_port) { + Ok(Some(process_id)) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "launcher.taskboard_injector_started", + json!({ + "debug_port": debug_port, + "process_id": process_id + }), + ); + } + Ok(None) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "launcher.taskboard_injector_skipped", + json!({ + "debug_port": debug_port, + "message": "Taskboard injector script was not found" + }), + ); + } + Err(error) => { + let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( + "launcher.taskboard_injector_failed", + json!({ + "debug_port": debug_port, + "message": error.to_string() + }), + ); + } + } +} + +fn spawn_taskboard_sidebar_injector(debug_port: u16) -> anyhow::Result> { + let Some(taskboard_root) = taskboard_root_from_current_exe() else { + return Ok(None); + }; + let injector = taskboard_root.join("scripts").join("codex-injector.mjs"); + let mut command = Command::new(taskboard_node_executable()); + command + .current_dir(&taskboard_root) + .arg(injector) + .arg("--daemon") + .arg("--port") + .arg(debug_port.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + command.creation_flags(codex_plus_core::windows_create_no_window()); + } + let child = command.spawn()?; + Ok(Some(child.id())) +} + +fn taskboard_root_from_current_exe() -> Option { + taskboard_root_from_env().or_else(|| { + std::env::current_exe() + .ok() + .and_then(|path| taskboard_root_for_exe_path(&path)) + }) +} + +fn taskboard_root_from_env() -> Option { + let root = std::env::var_os("CODEX_TASKBOARD_ROOT").map(PathBuf::from)?; + taskboard_injector_script_exists(&root).then_some(root) +} + +fn taskboard_root_for_exe_path(exe_path: &Path) -> Option { + let start = if exe_path.is_dir() { + exe_path + } else { + exe_path.parent()? + }; + for directory in start.ancestors() { + let root = directory.join("apps").join("codex-taskboard"); + if taskboard_injector_script_exists(&root) { + return Some(root); + } + } + None +} + +fn taskboard_injector_script_exists(root: &Path) -> bool { + root.join("scripts").join("codex-injector.mjs").is_file() +} + +fn taskboard_node_executable() -> PathBuf { + if let Some(path) = std::env::var_os("CODEX_TASKBOARD_NODE_EXE").map(PathBuf::from) { + if path.is_file() { + return path; + } + } + if let Some(path) = bundled_codex_node_executable() { + return path; + } + PathBuf::from(if cfg!(windows) { "node.exe" } else { "node" }) +} + +fn bundled_codex_node_executable() -> Option { + let home = directories::BaseDirs::new()?.home_dir().to_path_buf(); + let node = home + .join(".cache") + .join("codex-runtimes") + .join("codex-primary-runtime") + .join("dependencies") + .join("node") + .join("bin") + .join(if cfg!(windows) { "node.exe" } else { "node" }); + node.is_file().then_some(node) +} + fn parse_launch_options(args: I) -> LaunchOptions where I: IntoIterator, @@ -1042,6 +1174,36 @@ mod tests { assert_eq!(options.helper_port, LaunchOptions::default().helper_port); } + #[test] + fn taskboard_root_resolution_finds_dev_tree_from_debug_exe() { + let test_dir = std::env::temp_dir().join(format!( + "codex-plus-taskboard-root-test-{}", + std::process::id() + )); + let script = test_dir + .join("apps") + .join("codex-taskboard") + .join("scripts") + .join("codex-injector.mjs"); + std::fs::create_dir_all(script.parent().unwrap()).unwrap(); + std::fs::write(&script, "").unwrap(); + let exe = test_dir + .join("target") + .join("debug") + .join(if cfg!(windows) { + "codex-plus-plus.exe" + } else { + "codex-plus-plus" + }); + std::fs::create_dir_all(exe.parent().unwrap()).unwrap(); + + assert_eq!( + taskboard_root_for_exe_path(&exe), + Some(test_dir.join("apps").join("codex-taskboard")) + ); + let _ = std::fs::remove_dir_all(test_dir); + } + #[test] fn launcher_uses_single_instance_guard_before_launching() { let source = include_str!("main.rs"); @@ -1051,6 +1213,14 @@ mod tests { assert!(source.contains("launcher.already_running")); } + #[test] + fn launcher_retries_existing_codex_window_activation() { + let source = include_str!("main.rs"); + + assert!(source.contains("activate_codex_window_after_launch")); + assert!(source.contains("if !activated && launch_result.is_ok()")); + } + #[test] fn existing_launcher_path_drains_pending_remote_control_recovery_before_activation() { let source = include_str!("main.rs"); diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index c70022a21..851abc7d9 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -1,9 +1,12 @@ use std::collections::BTreeMap; use std::fs; -use std::io::{Read, Seek, SeekFrom}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::net::{SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use codex_plus_core::install::SILENT_BINARY; use codex_plus_core::models::{DeleteResult, SessionRef}; @@ -53,6 +56,14 @@ pub struct OverviewPayload { pub logs_path: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskboardPayload { + pub url: String, + pub already_running: bool, + pub launched: bool, +} + #[derive(Debug, Clone, Serialize)] pub struct SettingsPayload { pub settings: BackendSettings, @@ -553,6 +564,7 @@ pub fn restart_codex_plus(request: LaunchRequest) -> CommandResult { None }; codex_plus_core::watcher::stop_launcher_processes_and_wait(); + #[cfg(target_os = "macos")] codex_plus_core::watcher::stop_codex_processes_for_debug_port_and_wait(request.debug_port); let home = codex_plus_core::relay_config::default_codex_home_dir(); let _ = codex_plus_core::diagnostic_log::append_diagnostic_log( @@ -776,6 +788,100 @@ fn spawn_silent_launcher(request: &LaunchRequest) -> anyhow::Result<()> { codex_plus_core::install::spawn_companion(SILENT_BINARY, &args).map(|_| ()) } +const TASKBOARD_PORT: u16 = 47823; +const TASKBOARD_URL: &str = "http://127.0.0.1:47823/?host=codex"; + +#[tauri::command] +pub fn ensure_taskboard_service() -> CommandResult { + if taskboard_health_ok() { + return ok( + "Taskboard is already running.", + taskboard_payload(true, false), + ); + } + + if let Err(error) = spawn_taskboard_service() { + return failed( + &format!("Failed to start Taskboard with codex-taskboard: {error}"), + taskboard_payload(false, false), + ); + } + + if wait_for_taskboard_health(Duration::from_secs(8)) { + ok("Taskboard started.", taskboard_payload(false, true)) + } else { + failed( + "Started codex-taskboard, but http://127.0.0.1:47823/health is still unavailable.", + taskboard_payload(false, true), + ) + } +} + +fn taskboard_payload(already_running: bool, launched: bool) -> TaskboardPayload { + TaskboardPayload { + url: TASKBOARD_URL.to_string(), + already_running, + launched, + } +} + +fn wait_for_taskboard_health(timeout: Duration) -> bool { + let started_at = Instant::now(); + while started_at.elapsed() < timeout { + if taskboard_health_ok() { + return true; + } + thread::sleep(Duration::from_millis(250)); + } + false +} + +fn taskboard_health_ok() -> bool { + let address = SocketAddr::from(([127, 0, 0, 1], TASKBOARD_PORT)); + let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_millis(250)) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(750))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(750))); + if stream + .write_all(b"GET /health HTTP/1.1\r\nHost: 127.0.0.1:47823\r\nConnection: close\r\n\r\n") + .is_err() + { + return false; + } + let mut response = String::new(); + stream.read_to_string(&mut response).is_ok() + && response.contains(" 200 ") + && response.contains("\"status\":\"ok\"") +} + +fn spawn_taskboard_service() -> anyhow::Result<()> { + let mut command = taskboard_start_command(); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + command.spawn()?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn taskboard_start_command() -> Command { + let mut command = Command::new("cmd"); + command.args(["/C", "codex-taskboard"]); + command +} + +#[cfg(not(target_os = "windows"))] +fn taskboard_start_command() -> Command { + Command::new("codex-taskboard") +} + #[tauri::command] pub fn load_settings() -> CommandResult { settings_payload("设置已加载。", "设置读取失败") diff --git a/apps/codex-plus-manager/src-tauri/src/lib.rs b/apps/codex-plus-manager/src-tauri/src/lib.rs index da987661a..3d6670ff9 100644 --- a/apps/codex-plus-manager/src-tauri/src/lib.rs +++ b/apps/codex-plus-manager/src-tauri/src/lib.rs @@ -67,6 +67,7 @@ pub fn run() { commands::load_overview, commands::launch_codex_plus, commands::restart_codex_plus, + commands::ensure_taskboard_service, commands::load_settings, commands::save_settings, commands::dream_skin_status, diff --git a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs index 3cda81083..a3a5764e1 100644 --- a/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs +++ b/apps/codex-plus-manager/src-tauri/tests/windows_subsystem.rs @@ -95,7 +95,7 @@ fn launcher_binary_embeds_codex_icon_resource() { } #[test] -fn windows_binaries_request_administrator_privileges() { +fn windows_binaries_use_standard_user_execution_level() { let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); let manager_build = std::fs::read_to_string(manifest_dir.join("build.rs")).expect("read manager build.rs"); @@ -118,7 +118,7 @@ fn windows_binaries_request_administrator_privileges() { assert!(manager_build.contains("windows-app-manifest.xml")); assert!(launcher_build.contains("windows-app-manifest.xml")); - assert!(windows_manifest.contains("requireAdministrator")); + assert!(windows_manifest.contains("asInvoker")); assert!(windows_manifest.contains("Microsoft.Windows.Common-Controls")); assert!(windows_installer.contains("RequestExecutionLevel admin")); } diff --git a/apps/codex-plus-manager/src-tauri/windows-app-manifest.xml b/apps/codex-plus-manager/src-tauri/windows-app-manifest.xml index 3e50be5a6..f61760bdc 100644 --- a/apps/codex-plus-manager/src-tauri/windows-app-manifest.xml +++ b/apps/codex-plus-manager/src-tauri/windows-app-manifest.xml @@ -15,7 +15,7 @@ - + diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 2a7aadfaa..1f19f138a 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -32,6 +32,7 @@ import { GripVertical, Info, ImagePlus, + Kanban, Github, ExternalLink, Hammer, @@ -201,6 +202,7 @@ type BackendSettings = { providerSyncLastSelectedProvider: string; relayProfilesEnabled: boolean; enhancementsEnabled: boolean; + codexTaskboardEnabled: boolean; computerUseGuardEnabled: boolean; codexAppPluginMarketplaceUnlock: boolean; codexAppModelWhitelistUnlock: boolean; @@ -337,6 +339,7 @@ type RelayProtocol = "responses" | "chatCompletions"; type RelayMode = "official" | "mixedApi" | "pureApi" | "aggregate"; const CHAT_UPSTREAM_BASE_URL_KEY = "codex_plus_chat_base_url"; const SCRIPT_MARKET_REPOSITORY_URL = "https://github.com/BigPizzaV3/CodexPlusPlusScriptMarket"; +const TASKBOARD_PANEL_URL = "http://127.0.0.1:47823/?host=codex"; const emptyContextSelection = (): RelayContextSelection => ({ mcpServers: [], @@ -772,7 +775,13 @@ type StartupResult = CommandResult<{ showUpdate: boolean; }>; -type Route = "overview" | "relay" | "relayEnvironment" | "sessions" | "context" | "enhance" | "dreamSkin" | "zedRemote" | "userScripts" | "recommendations" | "maintenance" | "about" | "settings"; +type TaskboardServiceResult = CommandResult<{ + url: string; + alreadyRunning: boolean; + launched: boolean; +}>; + +type Route = "overview" | "relay" | "relayEnvironment" | "sessions" | "context" | "enhance" | "dreamSkin" | "taskboard" | "zedRemote" | "userScripts" | "recommendations" | "maintenance" | "about" | "settings"; type Theme = "dark" | "light"; const routes: Array<{ id: Route; label: string; icon: LucideIcon; badge?: string }> = [ @@ -782,6 +791,7 @@ const routes: Array<{ id: Route; label: string; icon: LucideIcon; badge?: string { id: "context", label: t("工具与插件"), icon: Network }, { id: "enhance", label: t("Codex增强"), icon: Hammer }, { id: "dreamSkin", label: t("皮肤管理"), icon: Palette }, + { id: "taskboard", label: "Taskboard", icon: Kanban }, { id: "zedRemote", label: t("Zed 远程项目"), icon: ExternalLink }, { id: "userScripts", label: t("脚本市场"), icon: FileCode2 }, { id: "recommendations", label: t("推荐内容"), icon: ExternalLink }, @@ -800,6 +810,7 @@ const defaultSettings: BackendSettings = { providerSyncLastSelectedProvider: "", relayProfilesEnabled: true, enhancementsEnabled: true, + codexTaskboardEnabled: false, computerUseGuardEnabled: false, codexAppPluginMarketplaceUnlock: true, codexAppModelWhitelistUnlock: true, @@ -886,6 +897,7 @@ const defaultSettings: BackendSettings = { export function App() { const [theme, setTheme] = useState(() => loadInitialTheme()); const [route, setRoute] = useState(() => loadInitialRoute()); + const [taskboardFrameVersion, setTaskboardFrameVersion] = useState(0); const [notice, setNotice] = useState<{ title: string; message: string; status?: Status } | null>(null); const [confirmDialog, setConfirmDialog] = useState<{ title: string; @@ -2603,7 +2615,13 @@ export function App() { const actions = useMemo( () => ({ - refreshCurrent: () => navigate(route), + refreshCurrent: async () => { + if (route === "taskboard") { + setTaskboardFrameVersion((version) => version + 1); + return; + } + await navigate(route); + }, launch, restart, repairPluginMarketplace, @@ -2803,6 +2821,7 @@ export function App() { [route, launchForm, settingsForm, settings, overview, removeOwnedData, update, updateInstallProgress.active, logs, diagnostics, theme, relayFiles, localSessions, zedRemoteProjects, selectedProviderSyncTarget, envConflicts, relayEnvironment, ccsProviders, dreamSkinLibrary, dreamSkinMarket, dreamSkinCommunity, selectedDreamSkinTheme, savedDreamSkinThemeDraft, dreamSkinThemeDraft, dreamSkinDraftDirty, pendingDreamSkinRestart], ); const hasUpdate = update?.updateAvailable === true; + const taskboardEnabled = settingsForm.enhancementsEnabled && settingsForm.codexTaskboardEnabled; return (
@@ -2829,7 +2848,7 @@ export function App() {