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 @@
-
+
需要安装 WorkBuddy 状态插件
@@ -73,7 +73,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));
+ }
}