From 5154cd16acf03e87c545bc005c77fb76d54e1cf0 Mon Sep 17 00:00:00 2001 From: STFQ <1256895841@qq.com> Date: Tue, 11 Aug 2026 21:23:03 +0800 Subject: [PATCH 1/2] Fix WorkBuddy sync and status feedback --- README.md | 3 +- index.html | 4 +- .../.codebuddy-plugin/plugin.json | 2 +- .../resources/workbuddy-plugin/hooks.json | 14 +- src-tauri/src/lib.rs | 125 ++++++-- src-tauri/src/workbuddy.rs | 275 ++++++++++++++++-- src/main.ts | 171 +++++++++-- src/styles.css | 25 +- 8 files changed, 541 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 116c09b..bbfe5be 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Realtime status needs the WorkBuddy plugin to be enabled. After enabling it, res The bundled plugin is status-only: - Includes `status-hook.mjs` -- Includes `status-hook.cmd` on Windows to locate WorkBuddy's bundled Node runtime +- Invokes `status-hook.mjs` through WorkBuddy's bundled `node` command on Windows +- Retains `status-hook.cmd` as a manual fallback launcher - Includes `status-runtime.mjs` - Does not install `approval-hook.mjs` diff --git a/index.html b/index.html index 49db2b5..e411d84 100644 --- a/index.html +++ b/index.html @@ -51,7 +51,7 @@ - -
+
diff --git a/src-tauri/resources/workbuddy-plugin/.codebuddy-plugin/plugin.json b/src-tauri/resources/workbuddy-plugin/.codebuddy-plugin/plugin.json index 723035f..15c1316 100644 --- a/src-tauri/resources/workbuddy-plugin/.codebuddy-plugin/plugin.json +++ b/src-tauri/resources/workbuddy-plugin/.codebuddy-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "workbuddy-buddy", - "version": "0.1.0", + "version": "0.1.1", "description": "Privacy-safe WorkBuddy lifecycle bridge and fail-open desktop-pet approval UI.", "author": { "name": "FlashFamily", diff --git a/src-tauri/resources/workbuddy-plugin/hooks.json b/src-tauri/resources/workbuddy-plugin/hooks.json index f46b8ce..c73de0a 100644 --- a/src-tauri/resources/workbuddy-plugin/hooks.json +++ b/src-tauri/resources/workbuddy-plugin/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "cmd /d /s /c \"\"${CODEBUDDY_PLUGIN_ROOT}\\scripts\\status-hook.cmd\" SessionStart\"", + "command": "node \"${CODEBUDDY_PLUGIN_ROOT}/scripts/status-hook.mjs\" SessionStart", "timeout": 10 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "cmd /d /s /c \"\"${CODEBUDDY_PLUGIN_ROOT}\\scripts\\status-hook.cmd\" UserPromptSubmit\"", + "command": "node \"${CODEBUDDY_PLUGIN_ROOT}/scripts/status-hook.mjs\" UserPromptSubmit", "timeout": 10 } ] @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "cmd /d /s /c \"\"${CODEBUDDY_PLUGIN_ROOT}\\scripts\\status-hook.cmd\" PreToolUse\"", + "command": "node \"${CODEBUDDY_PLUGIN_ROOT}/scripts/status-hook.mjs\" PreToolUse", "timeout": 10 } ] @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "cmd /d /s /c \"\"${CODEBUDDY_PLUGIN_ROOT}\\scripts\\status-hook.cmd\" PostToolUse\"", + "command": "node \"${CODEBUDDY_PLUGIN_ROOT}/scripts/status-hook.mjs\" PostToolUse", "timeout": 10 } ] @@ -52,7 +52,7 @@ "hooks": [ { "type": "command", - "command": "cmd /d /s /c \"\"${CODEBUDDY_PLUGIN_ROOT}\\scripts\\status-hook.cmd\" PermissionRequest\"", + "command": "node \"${CODEBUDDY_PLUGIN_ROOT}/scripts/status-hook.mjs\" PermissionRequest", "timeout": 10 } ] @@ -63,7 +63,7 @@ "hooks": [ { "type": "command", - "command": "cmd /d /s /c \"\"${CODEBUDDY_PLUGIN_ROOT}\\scripts\\status-hook.cmd\" Notification\"", + "command": "node \"${CODEBUDDY_PLUGIN_ROOT}/scripts/status-hook.mjs\" Notification", "timeout": 10 } ] @@ -74,7 +74,7 @@ "hooks": [ { "type": "command", - "command": "cmd /d /s /c \"\"${CODEBUDDY_PLUGIN_ROOT}\\scripts\\status-hook.cmd\" Stop\"", + "command": "node \"${CODEBUDDY_PLUGIN_ROOT}/scripts/status-hook.mjs\" Stop", "timeout": 10 } ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b74f687..7916e38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,16 +5,33 @@ use std::time::Duration; use tauri::menu::{Menu, MenuItem}; use tauri::tray::TrayIconBuilder; -use tauri::{Manager, PhysicalPosition}; +use tauri::{Emitter, Manager, PhysicalPosition}; -#[derive(Default)] -struct HitRect { +#[derive(Clone, Copy, Default)] +struct Rect { x: f64, y: f64, w: f64, h: f64, } +impl Rect { + fn contains(self, x: f64, y: f64) -> bool { + self.w > 0.0 + && self.h > 0.0 + && x >= self.x + && x <= self.x + self.w + && y >= self.y + && y <= self.y + self.h + } +} + +#[derive(Clone, Copy, Default)] +struct HitRegions { + pet: Rect, + panel: Rect, +} + fn pos_file() -> Option { dirs::config_dir().map(|dir| dir.join("Agent Buddy").join("workbuddy-pos")) } @@ -35,10 +52,34 @@ fn write_pos(x: i32, y: i32) { } #[tauri::command] -fn set_hit_rect(app: tauri::AppHandle, x: f64, y: f64, w: f64, h: f64) { - if let Some(state) = app.try_state::>() { - if let Ok(mut rect) = state.lock() { - *rect = HitRect { x, y, w, h }; +#[allow(clippy::too_many_arguments)] +fn set_hit_regions( + app: tauri::AppHandle, + pet_x: f64, + pet_y: f64, + pet_w: f64, + pet_h: f64, + panel_x: f64, + panel_y: f64, + panel_w: f64, + panel_h: f64, +) { + if let Some(state) = app.try_state::>() { + if let Ok(mut regions) = state.lock() { + *regions = HitRegions { + pet: Rect { + x: pet_x, + y: pet_y, + w: pet_w, + h: pet_h, + }, + panel: Rect { + x: panel_x, + y: panel_y, + w: panel_w, + h: panel_h, + }, + }; } } } @@ -63,9 +104,9 @@ pub fn run() { let _ = win.set_focus(); } })) - .manage(Mutex::new(HitRect::default())) + .manage(Mutex::new(HitRegions::default())) .invoke_handler(tauri::generate_handler![ - set_hit_rect, + set_hit_regions, set_pet_visible, workbuddy::workbuddy_snapshot, workbuddy::install_workbuddy_status_plugin, @@ -101,6 +142,7 @@ pub fn run() { std::thread::spawn(move || { let mut last_ignore: Option = None; let mut last_saved = read_pos(); + let mut interaction_active = false; let mut tick: u32 = 0; loop { std::thread::sleep(Duration::from_millis(30)); @@ -110,18 +152,20 @@ pub fn run() { match (handle.cursor_position(), win.outer_position()) { (Ok(cursor), Ok(window_pos)) => { - let rect = handle - .try_state::>() - .and_then(|state| state.lock().ok().map(|r| (r.x, r.y, r.w, r.h))); - let inside = match rect { - Some((x, y, w, h)) if w > 0.0 && h > 0.0 => { - let rx = cursor.x - window_pos.x as f64; - let ry = cursor.y - window_pos.y as f64; - rx >= x && rx <= x + w && ry >= y && ry <= y + h + let regions = handle + .try_state::>() + .and_then(|state| state.lock().ok().map(|regions| *regions)); + let scale = win.scale_factor().unwrap_or(1.0).max(f64::EPSILON); + let rx = (cursor.x - window_pos.x as f64) / scale; + let ry = (cursor.y - window_pos.y as f64) / scale; + interaction_active = match regions { + Some(regions) if regions.pet.w > 0.0 => { + regions.pet.contains(rx, ry) + || (interaction_active && regions.panel.contains(rx, ry)) } _ => true, }; - let ignore = !inside; + let ignore = !interaction_active; if Some(ignore) != last_ignore { let _ = win.set_ignore_cursor_events(ignore); last_ignore = Some(ignore); @@ -149,19 +193,56 @@ pub fn run() { let show = MenuItem::with_id(app, "show", "显示桌宠", true, None::<&str>)?; let hide = MenuItem::with_id(app, "hide", "隐藏桌宠", true, None::<&str>)?; - let install = - MenuItem::with_id(app, "install-plugin", "启用 WorkBuddy 实时状态", true, None::<&str>)?; + let plugin_status = workbuddy::current_plugin_status(); + let install_label = if plugin_status.is_ready() { + "✓ WorkBuddy 实时状态已启用" + } else { + "启用 WorkBuddy 实时状态" + }; + let install = MenuItem::with_id( + app, + "install-plugin", + install_label, + !plugin_status.is_ready(), + None::<&str>, + )?; let quit = MenuItem::with_id(app, "quit", "退出 Agent Buddy", true, None::<&str>)?; let menu = Menu::with_items(app, &[&show, &hide, &install, &quit])?; + let install_for_event = install.clone(); let mut tray = TrayIconBuilder::new() .tooltip("Agent Buddy") .menu(&menu) .show_menu_on_left_click(true) - .on_menu_event(|app, event| match event.id.as_ref() { + .on_menu_event(move |app, event| match event.id.as_ref() { "show" => set_pet_visible(app.clone(), true), "hide" => set_pet_visible(app.clone(), false), "install-plugin" => { - let _ = workbuddy::install_workbuddy_status_plugin(); + let _ = install_for_event.set_enabled(false); + let _ = install_for_event.set_text("正在启用 WorkBuddy 实时状态…"); + set_pet_visible(app.clone(), true); + + let app_handle = app.clone(); + let install_item = install_for_event.clone(); + std::thread::spawn(move || { + match workbuddy::install_workbuddy_status_plugin_blocking() { + Ok(status) => { + let _ = install_item.set_text("✓ WorkBuddy 实时状态已启用"); + let _ = install_item.set_enabled(false); + let _ = app_handle.emit( + "workbuddy-plugin-status", + serde_json::json!({ "status": status }), + ); + } + Err(error) => { + let _ = install_item.set_text("启用失败,点击重试"); + let _ = install_item.set_enabled(true); + let _ = app_handle.emit( + "workbuddy-plugin-status", + serde_json::json!({ "error": error }), + ); + } + } + }); } "quit" => app.exit(0), _ => {} diff --git a/src-tauri/src/workbuddy.rs b/src-tauri/src/workbuddy.rs index 90b9c4d..de21dc9 100644 --- a/src-tauri/src/workbuddy.rs +++ b/src-tauri/src/workbuddy.rs @@ -1,6 +1,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; @@ -8,6 +9,7 @@ use serde_json::{json, Map, Value}; const MARKETPLACE_ID: &str = "workbuddy-buddy"; const PLUGIN_ID: &str = "workbuddy-buddy@workbuddy-buddy"; +const PLUGIN_VERSION: &str = "0.1.1"; const DOWNLOAD_URL: &str = "https://www.workbuddy.cn/"; const PLUGIN_JSON: &str = include_str!("../resources/workbuddy-plugin/.codebuddy-plugin/plugin.json"); @@ -47,14 +49,15 @@ pub struct CreditSnapshot { error: Option, } -#[derive(Debug, Serialize)] +#[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PluginStatus { - host_installed: bool, - plugin_configured: bool, - marketplace_available: bool, - restart_required: bool, - message: String, + pub(crate) host_installed: bool, + pub(crate) plugin_configured: bool, + pub(crate) plugin_installed: bool, + pub(crate) marketplace_available: bool, + pub(crate) restart_required: bool, + pub(crate) message: String, } #[derive(Debug, Deserialize)] @@ -75,6 +78,18 @@ struct AuthSession { account_type: Option, } +struct WorkBuddyCli { + runner: PathBuf, + script: PathBuf, + electron_runner: bool, +} + +impl PluginStatus { + pub(crate) fn is_ready(&self) -> bool { + self.plugin_configured && self.plugin_installed && self.marketplace_available + } +} + #[tauri::command] pub fn workbuddy_snapshot() -> Snapshot { Snapshot { @@ -85,11 +100,26 @@ pub fn workbuddy_snapshot() -> Snapshot { } #[tauri::command] -pub fn install_workbuddy_status_plugin() -> Result { +pub async fn install_workbuddy_status_plugin() -> Result { + tauri::async_runtime::spawn_blocking(install_workbuddy_status_plugin_blocking) + .await + .map_err(|_| "启用 WorkBuddy 实时状态时后台任务异常退出。".to_owned())? +} + +pub(crate) fn install_workbuddy_status_plugin_blocking() -> Result { let home = home_dir()?; install_plugin_files(&home)?; + install_plugin_with_workbuddy(&home)?; enable_plugin_in_settings(&home)?; - Ok(plugin_status(true)) + let status = plugin_status(true); + if !status.is_ready() { + return Err("WorkBuddy 插件管理器没有完成实时状态插件的注册,请重启 WorkBuddy 后重试。".to_owned()); + } + Ok(status) +} + +pub(crate) fn current_plugin_status() -> PluginStatus { + plugin_status(false) } #[tauri::command] @@ -446,18 +476,31 @@ fn matches_domain(domain: &str, patterns: Option<&Value>) -> bool { fn plugin_status(restart_required: bool) -> PluginStatus { let home = dirs::home_dir(); let host_installed = home.as_deref().is_some_and(host_is_installed); - let plugin_configured = home + let settings_enabled = home .as_deref() .and_then(|home| read_json_file(&settings_path(home))) .is_some_and(|settings| plugin_is_enabled(&settings)); let marketplace_available = home + .as_deref() + .is_some_and(|home| marketplace_is_registered(home) && marketplace_manifest_path(home).is_file()); + let plugin_installed = home + .as_deref() + .is_some_and(plugin_is_currently_installed); + let plugin_configured = settings_enabled && marketplace_available && plugin_installed; + let has_any_event = home .as_ref() - .map(|home| marketplace_root(home).join(".codebuddy-plugin").join("marketplace.json").is_file()) + .map(|home| home.join(".workbuddy-buddy").join("events.spool").is_file()) .unwrap_or(false); let message = if restart_required { "已启用,请重启 WorkBuddy 后新开任务。" } else if plugin_configured { - "实时状态插件已启用。" + if has_any_event { + "实时状态已启用并已连接 WorkBuddy。" + } else { + "实时状态已启用;重启 WorkBuddy 后新开任务即可同步。" + } + } else if settings_enabled || marketplace_available || installed_plugin_version(home.as_deref()).is_some() { + "检测到未完成或过期的状态插件,点击可自动修复。" } else if !host_installed { "未检测到 WorkBuddy,请先安装并登录。" } else { @@ -468,6 +511,7 @@ fn plugin_status(restart_required: bool) -> PluginStatus { PluginStatus { host_installed, plugin_configured, + plugin_installed, marketplace_available, restart_required, message, @@ -493,6 +537,119 @@ fn install_plugin_files(home: &Path) -> Result<(), String> { Ok(()) } +fn install_plugin_with_workbuddy(home: &Path) -> Result<(), String> { + let cli = find_workbuddy_cli(home)?; + if !marketplace_is_registered(home) { + let marketplace = marketplace_root(home).to_string_lossy().into_owned(); + run_workbuddy_cli( + &cli, + home, + &["plugin", "marketplace", "add", &marketplace, "--name", MARKETPLACE_ID], + "注册本地插件源", + )?; + } + + if installed_plugin_version(Some(home)).is_some() { + run_workbuddy_cli( + &cli, + home, + &["plugin", "update", PLUGIN_ID, "--scope", "user"], + "更新实时状态插件", + )?; + } else { + run_workbuddy_cli( + &cli, + home, + &["plugin", "install", PLUGIN_ID, "--scope", "user"], + "安装实时状态插件", + )?; + } + + if !marketplace_is_registered(home) || !plugin_is_currently_installed(home) { + return Err("WorkBuddy 插件管理器未能完成实时状态插件安装。".to_owned()); + } + Ok(()) +} + +fn find_workbuddy_cli(home: &Path) -> Result { + let script = product_dirs() + .into_iter() + .map(|directory| directory.join("bin").join("codebuddy")) + .find(|path| path.is_file()) + .ok_or_else(|| "未找到 WorkBuddy 命令行组件,请更新或重新安装 WorkBuddy。".to_owned())?; + + if let Some(runner) = workbuddy_node(home) { + return Ok(WorkBuddyCli { + runner, + script, + electron_runner: false, + }); + } + + #[cfg(windows)] + if let Some(runner) = script + .ancestors() + .map(|directory| directory.join("WorkBuddy.exe")) + .find(|path| path.is_file()) + { + return Ok(WorkBuddyCli { + runner, + script, + electron_runner: true, + }); + } + + Err("未找到 WorkBuddy 内置 Node.js 运行时,请先在 WorkBuddy 中新建一次任务。".to_owned()) +} + +fn workbuddy_node(home: &Path) -> Option { + let versions = home.join(".workbuddy").join("binaries").join("node").join("versions"); + let mut candidates = fs::read_dir(versions) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path().join(if cfg!(windows) { "node.exe" } else { "bin/node" })) + .filter(|path| path.is_file()) + .collect::>(); + candidates.sort_by(|left, right| right.cmp(left)); + candidates.into_iter().next() +} + +fn run_workbuddy_cli( + cli: &WorkBuddyCli, + home: &Path, + arguments: &[&str], + action: &str, +) -> Result<(), String> { + let config_dir = home.join(".workbuddy"); + let mut command = Command::new(&cli.runner); + command + .arg(&cli.script) + .args(arguments) + .current_dir(home) + .env("CODEBUDDY_CONFIG_DIR", &config_dir) + .env("WORKBUDDY_CONFIG_DIR", &config_dir) + .env("WORKBUDDY_DATA_FOLDER_NAME", ".workbuddy") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if cli.electron_runner { + command.env("ELECTRON_RUN_AS_NODE", "1"); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); + } + + let output = command + .output() + .map_err(|_| format!("无法调用 WorkBuddy 插件管理器来{action}。"))?; + if !output.status.success() { + return Err(format!("WorkBuddy 插件管理器{action}失败。")); + } + Ok(()) +} + fn enable_plugin_in_settings(home: &Path) -> Result<(), String> { let path = settings_path(home); let mut settings = read_json_file(&path).unwrap_or_else(|| json!({})); @@ -505,8 +662,9 @@ fn enable_plugin_in_settings(home: &Path) -> Result<(), String> { MARKETPLACE_ID.to_owned(), json!({ "source": { - "source": "local", - "path": marketplace_root(home).to_string_lossy() + "source": "directory", + "path": marketplace_root(home).to_string_lossy(), + "url": marketplace_root(home).to_string_lossy() } }), ); @@ -525,6 +683,52 @@ fn plugin_is_enabled(settings: &Value) -> bool { == Some(true) } +fn marketplace_manifest_path(home: &Path) -> PathBuf { + marketplace_root(home) + .join(".codebuddy-plugin") + .join("marketplace.json") +} + +fn marketplace_registry_path(home: &Path) -> PathBuf { + home.join(".workbuddy") + .join("plugins") + .join("known_marketplaces.json") +} + +fn installed_plugins_path(home: &Path) -> PathBuf { + home.join(".workbuddy") + .join("plugins") + .join("installed_plugins.json") +} + +fn marketplace_is_registered(home: &Path) -> bool { + read_json_file(&marketplace_registry_path(home)) + .and_then(|registry| registry.get(MARKETPLACE_ID).cloned()) + .is_some() +} + +fn installed_plugin_version(home: Option<&Path>) -> Option { + let registry = read_json_file(&installed_plugins_path(home?))?; + registry + .get("plugins")? + .get(PLUGIN_ID)? + .as_array()? + .iter() + .find(|entry| { + entry + .get("installPath") + .and_then(Value::as_str) + .is_some_and(|path| Path::new(path).is_dir()) + }) + .and_then(|entry| entry.get("version")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} + +fn plugin_is_currently_installed(home: &Path) -> bool { + installed_plugin_version(Some(home)).as_deref() == Some(PLUGIN_VERSION) +} + fn marketplace_json(plugin: &Path) -> String { let source = plugin .file_name() @@ -533,15 +737,23 @@ fn marketplace_json(plugin: &Path) -> String { .unwrap_or_else(|| "./plugins/workbuddy-buddy".to_owned()); serde_json::to_string_pretty(&json!({ "name": "workbuddy-buddy", - "version": "0.1.0", "description": "Agent Buddy bundled WorkBuddy status plugin marketplace.", + "owner": { + "name": "Agent Buddy" + }, + "metadata": { + "version": PLUGIN_VERSION + }, "plugins": [ { - "id": "workbuddy-buddy", - "name": "Agent Buddy", - "version": "0.1.0", + "name": MARKETPLACE_ID, + "version": PLUGIN_VERSION, "description": "Status-only WorkBuddy lifecycle bridge. No approval hook is enabled.", "source": source, + "category": "utility", + "author": { + "name": "Agent Buddy" + }, "license": "MIT" } ] @@ -577,7 +789,11 @@ fn write_settings_atomically(path: &Path, settings: &Value) -> Result<(), String } } - let tmp = parent.join(format!(".settings.agent-buddy-{}.tmp", std::process::id())); + let tmp = parent.join(format!( + ".settings.agent-buddy-{}-{}.tmp", + std::process::id(), + now_millis() + )); let serialized = serde_json::to_vec_pretty(settings) .map_err(|_| "无法序列化 WorkBuddy settings.json。".to_owned())?; { @@ -655,3 +871,26 @@ fn dedupe(paths: Vec) -> Vec { } result } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marketplace_uses_workbuddy_plugin_name() { + let manifest: Value = serde_json::from_str(&marketplace_json(Path::new("workbuddy-buddy"))).unwrap(); + assert_eq!(manifest.pointer("/plugins/0/name").and_then(Value::as_str), Some(MARKETPLACE_ID)); + assert_eq!(manifest.pointer("/plugins/0/version").and_then(Value::as_str), Some(PLUGIN_VERSION)); + } + + #[test] + fn hooks_use_cross_platform_node_command() { + let manifest: Value = serde_json::from_str(HOOKS_JSON).unwrap(); + let command = manifest + .pointer("/hooks/SessionStart/0/hooks/0/command") + .and_then(Value::as_str) + .unwrap(); + assert!(command.starts_with("node \"${CODEBUDDY_PLUGIN_ROOT}/")); + assert!(!command.contains("cmd /d")); + } +} diff --git a/src/main.ts b/src/main.ts index 16ceaf8..3a50d38 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; type WorkState = "idle" | "thinking" | "tool" | "output" | "waiting" | "done" | "unknown"; @@ -25,11 +26,17 @@ interface CreditSnapshot { interface PluginStatus { hostInstalled: boolean; pluginConfigured: boolean; + pluginInstalled: boolean; marketplaceAvailable: boolean; restartRequired: boolean; message: string; } +interface PluginInstallFeedback { + status?: PluginStatus; + error?: string; +} + interface Snapshot { activity: ActivitySnapshot; credits: CreditSnapshot; @@ -40,6 +47,7 @@ const stage = document.querySelector("#pet-stage")!; const panel = document.querySelector("#credit-panel")!; const petCard = document.querySelector("#pet-card")!; const stateBadge = document.querySelector("#state-badge")!; +const stand = document.querySelector("#stand")!; const statusText = document.querySelector("#status-text")!; const creditLeft = document.querySelector("#credit-left")!; const creditProgress = document.querySelector("#credit-progress")!; @@ -53,8 +61,15 @@ const setupRow = document.querySelector("#setup-row")!; const setupMessage = document.querySelector("#setup-message")!; const installPlugin = document.querySelector("#install-plugin")!; -const windowRef = getCurrentWindow(); +const hasTauriRuntime = "__TAURI_INTERNALS__" in window; +const windowRef = hasTauriRuntime ? getCurrentWindow() : null; let latestSnapshot: Snapshot | null = null; +let refreshInFlight = false; +let installInProgress = false; +let pluginFeedback: PluginStatus | null = null; +let pluginFeedbackError: string | null = null; +let pluginFeedbackUntil = 0; +let feedbackTimer: number | undefined; function fmtNumber(value?: number): string { if (value === undefined || Number.isNaN(value)) return "--"; @@ -97,6 +112,74 @@ function stateMeta(state: WorkState): { label: string; icon: string; className: } } +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function pluginIsReady(status: PluginStatus): boolean { + return status.pluginConfigured && status.pluginInstalled && status.marketplaceAvailable; +} + +function currentPluginStatus(): PluginStatus | null { + if (pluginFeedback && Date.now() < pluginFeedbackUntil) return pluginFeedback; + pluginFeedback = null; + if (Date.now() >= pluginFeedbackUntil) pluginFeedbackError = null; + return latestSnapshot?.plugin ?? null; +} + +function renderPluginStatus(status: PluginStatus) { + if (installInProgress) { + renderPluginWorking(); + return; + } + + const ready = pluginIsReady(status); + setupRow.hidden = false; + installPlugin.removeAttribute("aria-busy"); + setupRow.dataset.status = ready ? (status.restartRequired ? "restart" : "enabled") : "disabled"; + installPlugin.disabled = ready; + installPlugin.textContent = ready ? "✓ 实时状态已启用" : "启用实时状态"; + setupMessage.textContent = status.message; +} + +function renderPluginWorking() { + setupRow.hidden = false; + setupRow.dataset.status = "working"; + installPlugin.disabled = true; + installPlugin.textContent = "正在启用…"; + installPlugin.setAttribute("aria-busy", "true"); + setupMessage.textContent = "正在通过 WorkBuddy 插件管理器完成安装和校验…"; +} + +function renderPluginError(error: string) { + setupRow.hidden = false; + setupRow.dataset.status = "error"; + installPlugin.disabled = false; + installPlugin.textContent = "重试启用"; + installPlugin.removeAttribute("aria-busy"); + setupMessage.textContent = error; +} + +function showPluginFeedback(status?: PluginStatus, error?: string) { + window.clearTimeout(feedbackTimer); + stage.classList.add("feedback-visible"); + feedbackTimer = window.setTimeout(() => stage.classList.remove("feedback-visible"), 8000); + + if (status) { + pluginFeedback = status; + pluginFeedbackError = null; + pluginFeedbackUntil = Date.now() + 8000; + renderPluginStatus(status); + return; + } + + if (error) { + pluginFeedbackError = error; + pluginFeedbackUntil = Date.now() + 8000; + renderPluginError(error); + } +} + function applySnapshot(snapshot: Snapshot) { latestSnapshot = snapshot; const meta = stateMeta(snapshot.activity.state); @@ -114,60 +197,96 @@ function applySnapshot(snapshot: Snapshot) { metricUpdated.textContent = fmtTime(credits.updatedAt); creditProgress.style.width = `${Math.max(0, Math.min(100, credits.percent ?? 0))}%`; - setupRow.hidden = snapshot.plugin.pluginConfigured; - setupMessage.textContent = snapshot.plugin.message; + const status = currentPluginStatus() ?? snapshot.plugin; + if (pluginFeedbackError && Date.now() < pluginFeedbackUntil) { + renderPluginError(pluginFeedbackError); + } else { + renderPluginStatus(status); + } } async function refreshSnapshot() { + if (!hasTauriRuntime) return; + if (refreshInFlight) return; + refreshInFlight = true; try { const snapshot = await invoke("workbuddy_snapshot"); applySnapshot(snapshot); } catch (error) { console.error(error); + } finally { + refreshInFlight = false; } } -function reportHitRect() { +function unionRect(rects: DOMRect[], padding: number, bodyRect: DOMRect) { + const left = Math.min(...rects.map((rect) => rect.left)) - bodyRect.left - padding; + const top = Math.min(...rects.map((rect) => rect.top)) - bodyRect.top - padding; + const right = Math.max(...rects.map((rect) => rect.right)) - bodyRect.left + padding; + const bottom = Math.max(...rects.map((rect) => rect.bottom)) - bodyRect.top + padding; + return { x: left, y: top, w: right - left, h: bottom - top }; +} + +function reportHitRegions() { + if (!hasTauriRuntime) return; const bodyRect = document.body.getBoundingClientRect(); - const rects = [petCard.getBoundingClientRect()]; - if (panel.matches(":hover") || petCard.matches(":hover")) rects.push(panel.getBoundingClientRect()); - const left = Math.min(...rects.map((rect) => rect.left)) - bodyRect.left; - const top = Math.min(...rects.map((rect) => rect.top)) - bodyRect.top; - const right = Math.max(...rects.map((rect) => rect.right)) - bodyRect.left; - const bottom = Math.max(...rects.map((rect) => rect.bottom)) - bodyRect.top; - void invoke("set_hit_rect", { x: left, y: top, w: right - left, h: bottom - top }); + const pet = unionRect( + [petCard.getBoundingClientRect(), stateBadge.getBoundingClientRect(), stand.getBoundingClientRect()], + 8, + bodyRect, + ); + const panelRegion = unionRect([panel.getBoundingClientRect()], 20, bodyRect); + void invoke("set_hit_regions", { + petX: pet.x, + petY: pet.y, + petW: pet.w, + petH: pet.h, + panelX: panelRegion.x, + panelY: panelRegion.y, + panelW: panelRegion.w, + panelH: panelRegion.h, + }); } petCard.addEventListener("mousedown", async (event) => { - if (event.button !== 0) return; + if (event.button !== 0 || !windowRef) return; await windowRef.startDragging(); }); installPlugin.addEventListener("click", async () => { - installPlugin.disabled = true; - setupMessage.textContent = "正在启用状态插件…"; + installInProgress = true; + renderPluginWorking(); + stage.classList.add("feedback-visible"); + let status: PluginStatus | undefined; + let errorMessage: string | undefined; try { - const status = await invoke("install_workbuddy_status_plugin"); - setupMessage.textContent = status.message; - await refreshSnapshot(); + if (!hasTauriRuntime) { + await new Promise((resolve) => window.setTimeout(resolve, 1000)); + throw new Error("仅可在 Agent Buddy 桌面应用中启用实时状态。"); + } + status = await invoke("install_workbuddy_status_plugin"); } catch (error) { - setupMessage.textContent = String(error); + errorMessage = errorText(error); } finally { - installPlugin.disabled = false; + installInProgress = false; + showPluginFeedback(status, errorMessage); } }); -panel.addEventListener("mouseenter", reportHitRect); -panel.addEventListener("mouseleave", reportHitRect); -petCard.addEventListener("mouseenter", reportHitRect); -petCard.addEventListener("mouseleave", reportHitRect); -window.addEventListener("resize", reportHitRect); +window.addEventListener("resize", reportHitRegions); + +if (hasTauriRuntime) { + void listen("workbuddy-plugin-status", ({ payload }) => { + installInProgress = false; + showPluginFeedback(payload.status, payload.error); + }); +} setInterval(refreshSnapshot, 800); -setInterval(reportHitRect, 120); +setInterval(reportHitRegions, 120); void refreshSnapshot(); -reportHitRect(); +reportHitRegions(); document.addEventListener("contextmenu", (event) => event.preventDefault()); diff --git a/src/styles.css b/src/styles.css index e474784..e8f101b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -45,7 +45,8 @@ body { pointer-events: none; } -#pet-stage:hover .credit-panel { +#pet-stage:hover .credit-panel, +#pet-stage.feedback-visible .credit-panel { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; @@ -179,9 +180,31 @@ body { .setup-row button:disabled { opacity: 0.6; + cursor: default; +} + +.setup-row[data-status="working"] button { cursor: wait; } +.setup-row[data-status="enabled"] button, +.setup-row[data-status="restart"] button { + background: #20a75a; + opacity: 1; +} + +.setup-row[data-status="restart"] span { + color: #9a6810; +} + +.setup-row[data-status="error"] button { + background: #d94852; +} + +.setup-row[data-status="error"] span { + color: #b32f39; +} + .pet-card { position: absolute; left: 128px; From 59fcb8adedc6ac7441be973fc358219c2f2e75b5 Mon Sep 17 00:00:00 2001 From: STFQ <1256895841@qq.com> Date: Tue, 11 Aug 2026 21:52:14 +0800 Subject: [PATCH 2/2] Handle WorkBuddy directory plugin installs --- .github/workflows/build-windows.yml | 3 + README.md | 2 + src-tauri/src/workbuddy.rs | 107 ++++++++++++++++++++++++++-- 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 584f6b2..21f7e0a 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -37,6 +37,9 @@ jobs: - name: Build frontend run: npm run build + - name: Test Rust backend + run: cargo test --manifest-path src-tauri/Cargo.toml + - name: Build Tauri Windows app and installers run: npx tauri build --bundles nsis,msi diff --git a/README.md b/README.md index bbfe5be..ea754d0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ The bundled plugin is status-only: - Includes `status-hook.mjs` - Invokes `status-hook.mjs` through WorkBuddy's bundled `node` command on Windows +- Accepts WorkBuddy's directory-linked plugin installs even when its CLI does not create a cache registry entry +- Migrates obsolete local marketplace settings to WorkBuddy's current directory-source schema - Retains `status-hook.cmd` as a manual fallback launcher - Includes `status-runtime.mjs` - Does not install `approval-hook.mjs` diff --git a/src-tauri/src/workbuddy.rs b/src-tauri/src/workbuddy.rs index de21dc9..dd97b32 100644 --- a/src-tauri/src/workbuddy.rs +++ b/src-tauri/src/workbuddy.rs @@ -478,8 +478,10 @@ fn plugin_status(restart_required: bool) -> PluginStatus { let host_installed = home.as_deref().is_some_and(host_is_installed); let settings_enabled = home .as_deref() - .and_then(|home| read_json_file(&settings_path(home))) - .is_some_and(|settings| plugin_is_enabled(&settings)); + .is_some_and(|home| { + read_json_file(&settings_path(home)) + .is_some_and(|settings| plugin_is_enabled(&settings, home)) + }); let marketplace_available = home .as_deref() .is_some_and(|home| marketplace_is_registered(home) && marketplace_manifest_path(home).is_file()); @@ -675,12 +677,29 @@ fn enable_plugin_in_settings(home: &Path) -> Result<(), String> { write_settings_atomically(&path, &settings) } -fn plugin_is_enabled(settings: &Value) -> bool { - settings +fn plugin_is_enabled(settings: &Value, home: &Path) -> bool { + let enabled = settings .get("enabledPlugins") .and_then(|value| value.get(PLUGIN_ID)) .and_then(Value::as_bool) - == Some(true) + == Some(true); + enabled && marketplace_setting_is_current(settings, home) +} + +fn marketplace_setting_is_current(settings: &Value, home: &Path) -> bool { + let source = settings + .get("extraKnownMarketplaces") + .and_then(|value| value.get(MARKETPLACE_ID)) + .and_then(|value| value.get("source")); + let source_type = source + .and_then(|value| value.get("source")) + .and_then(Value::as_str); + let source_url = source + .and_then(|value| value.get("url")) + .and_then(Value::as_str); + + source_type == Some("directory") + && source_url.is_some_and(|path| Path::new(path) == marketplace_root(home)) } fn marketplace_manifest_path(home: &Path) -> PathBuf { @@ -725,8 +744,36 @@ fn installed_plugin_version(home: Option<&Path>) -> Option { .map(ToOwned::to_owned) } +fn marketplace_plugin_version(home: &Path) -> Option { + read_json_file( + &marketplace_root(home) + .join("plugins") + .join(MARKETPLACE_ID) + .join(".codebuddy-plugin") + .join("plugin.json"), + )? + .get("version")? + .as_str() + .map(ToOwned::to_owned) +} + +fn plugin_installation_is_current( + cached_version: Option<&str>, + marketplace_version: Option<&str>, + marketplace_registered: bool, +) -> bool { + cached_version == Some(PLUGIN_VERSION) + || (marketplace_registered && marketplace_version == Some(PLUGIN_VERSION)) +} + fn plugin_is_currently_installed(home: &Path) -> bool { - installed_plugin_version(Some(home)).as_deref() == Some(PLUGIN_VERSION) + let cached_version = installed_plugin_version(Some(home)); + let marketplace_version = marketplace_plugin_version(home); + plugin_installation_is_current( + cached_version.as_deref(), + marketplace_version.as_deref(), + marketplace_is_registered(home), + ) } fn marketplace_json(plugin: &Path) -> String { @@ -893,4 +940,52 @@ mod tests { assert!(command.starts_with("node \"${CODEBUDDY_PLUGIN_ROOT}/")); assert!(!command.contains("cmd /d")); } + + #[test] + fn accepts_cached_and_registered_directory_plugin_installations() { + assert!(plugin_installation_is_current(Some(PLUGIN_VERSION), None, false)); + assert!(plugin_installation_is_current(None, Some(PLUGIN_VERSION), true)); + assert!(!plugin_installation_is_current(None, Some(PLUGIN_VERSION), false)); + assert!(!plugin_installation_is_current(None, Some("0.1.0"), true)); + } + + #[test] + fn rejects_legacy_local_marketplace_setting() { + let home = Path::new("test-home"); + let legacy = json!({ + "enabledPlugins": { + (PLUGIN_ID): true + }, + "extraKnownMarketplaces": { + (MARKETPLACE_ID): { + "source": { + "source": "local", + "path": marketplace_root(home).to_string_lossy() + } + } + } + }); + assert!(!plugin_is_enabled(&legacy, home)); + } + + #[test] + fn accepts_current_directory_marketplace_setting() { + let home = Path::new("test-home"); + let marketplace = marketplace_root(home).to_string_lossy().into_owned(); + let current = json!({ + "enabledPlugins": { + (PLUGIN_ID): true + }, + "extraKnownMarketplaces": { + (MARKETPLACE_ID): { + "source": { + "source": "directory", + "path": marketplace, + "url": marketplace + } + } + } + }); + assert!(plugin_is_enabled(¤t, home)); + } }