From bc1d54055ac0791452c5e943cf623aad4bbd335c Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Sun, 8 Feb 2026 00:30:08 -0500 Subject: [PATCH 01/16] feat: add full Windows system tray support (#77) This commit adds comprehensive Windows system tray support with feature parity to macOS: Features: - Tray icon appears in Windows system tray with green gauge icon - Left-click toggles window visibility (show/hide) - Right-click shows context menu with live provider usage data - Menu shows provider percentages (e.g., 'Cursor: 65%') and auto-updates - Window positioned above tray icon with proper monitor clamping - Platform-specific arrow: points DOWN on Windows (toward taskbar), UP on macOS - Backend-managed tray icon (frontend won't override) Technical Changes: - Added window_manager.rs for platform abstraction (Windows vs macOS) - Updated tray.rs with Windows-specific icon loading and click handlers - Modified lib.rs to store probe results and trigger menu updates - Updated tauri.conf.json with Windows window config (decorations: false, transparent) - Frontend detects platform and renders correct arrow direction - Added CSS for .tray-arrow-down pointing toward bottom taskbar - Moved tauri-nspanel to macOS-only dependencies (was breaking Windows build) - Added tauri-plugin-os for platform detection Files Changed: - src-tauri/src/tray.rs: Windows tray implementation - src-tauri/src/window_manager.rs: Platform window management (NEW) - src-tauri/src/lib.rs: Probe result storage, tray refresh integration - src-tauri/Cargo.toml: Platform-specific dependencies - src-tauri/tauri.conf.json: Window config, resources - src/App.tsx: Platform detection, arrow positioning - src/index.css: .tray-arrow-down styles, transparent fixes - package.json: Added @tauri-apps/plugin-os Closes #77 --- package.json | 1 + src-tauri/Cargo.toml | 3 +- src-tauri/src/lib.rs | 41 ++- src-tauri/src/tray.rs | 482 +++++++++++++++++++++++++++----- src-tauri/src/window_manager.rs | 168 +++++++++++ src-tauri/tauri.conf.json | 7 +- src/App.tsx | 158 +++-------- src/index.css | 36 ++- 8 files changed, 681 insertions(+), 215 deletions(-) create mode 100644 src-tauri/src/window_manager.rs diff --git a/package.json b/package.json index 0d7b20e8..ce361d0a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", "@tauri-apps/plugin-updater": "^2.10.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6ea7d9a3..815cc447 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,9 +20,9 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] } tauri-plugin-opener = "2" +tauri-plugin-os = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } time = { version = "0.3.47", features = ["formatting"] } dirs = "6" log = "0.4" @@ -39,6 +39,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } regex-lite = "0.1.9" [target.'cfg(target_os = "macos")'.dependencies] +tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSProcessInfo", "NSString"] } objc2-web-kit = { version = "0.3", features = ["WKPreferences", "WKWebView", "WKWebViewConfiguration"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c5fcda9..06d03d85 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ #[cfg(target_os = "macos")] mod app_nap; +#[cfg(target_os = "macos")] mod panel; mod plugin_engine; mod tray; +mod window_manager; #[cfg(target_os = "macos")] mod webkit_config; @@ -13,7 +15,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use serde::Serialize; -use tauri::Emitter; +use tauri::{Emitter, Manager}; use tauri_plugin_log::{Target, TargetKind}; use uuid::Uuid; @@ -21,6 +23,7 @@ pub struct AppState { pub plugins: Vec, pub app_data_dir: PathBuf, pub app_version: String, + pub latest_probe_results: std::collections::HashMap, } #[derive(Debug, Clone, Serialize)] @@ -67,15 +70,12 @@ pub struct ProbeBatchComplete { #[tauri::command] fn init_panel(app_handle: tauri::AppHandle) { - panel::init(&app_handle).expect("Failed to initialize panel"); + window_manager::WindowManager::init(&app_handle).expect("Failed to initialize window"); } #[tauri::command] fn hide_panel(app_handle: tauri::AppHandle) { - use tauri_nspanel::ManagerExt; - if let Ok(panel) = app_handle.get_webview_panel("main") { - panel.hide(); - } + window_manager::WindowManager::hide(&app_handle).expect("Failed to hide window"); } #[tauri::command] @@ -174,6 +174,15 @@ async fn start_probe_batch( } else { log::info!("probe {} completed ok ({} lines)", plugin_id, output.lines.len()); } + + // Store result in AppState for tray menu access + { + let state = handle.state::>(); + if let Ok(mut app_state) = state.lock() { + app_state.latest_probe_results.insert(plugin_id.clone(), output.clone()); + } + } + let _ = handle.emit("probe:result", ProbeResult { batch_id: bid, output }); } Err(_) => { @@ -189,6 +198,8 @@ async fn start_probe_batch( batch_id: completion_bid, }, ); + // Refresh tray menu with new data + let _ = tray::update_tray_menu(&completion_handle); } }); } @@ -257,11 +268,12 @@ pub fn run() { let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let _guard = runtime.enter(); - tauri::Builder::default() + #[cfg_attr(not(target_os = "macos"), allow(unused_mut))] + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_aptabase::Builder::new("A-US-6435241436").build()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::default().build()) - .plugin(tauri_nspanel::init()) + .plugin(tauri_plugin_os::init()) .plugin( tauri_plugin_log::Builder::new() .targets([ @@ -282,8 +294,16 @@ pub fn run() { hide_panel, start_probe_batch, list_plugins, - get_log_path - ]) + get_log_path, + tray::refresh_tray_menu + ]); + + #[cfg(target_os = "macos")] + { + builder = builder.plugin(tauri_nspanel::init()); + } + + builder .setup(|app| { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); @@ -310,6 +330,7 @@ pub fn run() { plugins, app_data_dir, app_version: app.package_info().version.to_string(), + latest_probe_results: std::collections::HashMap::new(), })); tray::create(app.handle())?; diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 7859ed31..40b21be0 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,12 +1,16 @@ +use std::collections::HashMap; +use std::sync::Mutex; + use tauri::image::Image; use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::path::BaseDirectory; use tauri::tray::{MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager}; -use tauri_nspanel::ManagerExt; use tauri_plugin_store::StoreExt; -use crate::panel::position_panel_at_tray_icon; +use crate::plugin_engine::runtime::{MetricLine, PluginOutput}; +use crate::window_manager::{position_window_at_tray, WindowManager}; +use crate::AppState; const LOG_LEVEL_STORE_KEY: &str = "logLevel"; @@ -23,7 +27,7 @@ fn get_stored_log_level(app_handle: &AppHandle) -> log::LevelFilter { Some("info") => log::LevelFilter::Info, Some("debug") => log::LevelFilter::Debug, Some("trace") => log::LevelFilter::Trace, - _ => log::LevelFilter::Error, // Default: least verbose + _ => log::LevelFilter::Error, } } @@ -44,95 +48,381 @@ fn set_stored_log_level(app_handle: &AppHandle, level: log::LevelFilter) { log::set_max_level(level); } +/// Build a dynamic tray menu with plugin data +fn build_tray_menu( + app_handle: &AppHandle, + probe_results: &HashMap, +) -> tauri::Result> { + let state = app_handle.state::>(); + let plugins = if let Ok(app_state) = state.lock() { + app_state.plugins.clone() + } else { + vec![] + }; + + // Build static menu items first + let show_stats = MenuItem::with_id(app_handle, "show_stats", "Show Stats", true, None::<&str>)?; + let go_to_settings = MenuItem::with_id( + app_handle, + "go_to_settings", + "Go to Settings", + true, + None::<&str>, + )?; -macro_rules! get_or_init_panel { - ($app_handle:expr) => { - match $app_handle.get_webview_panel("main") { - Ok(panel) => Some(panel), - Err(_) => { - if let Err(err) = crate::panel::init($app_handle) { - log::error!("Failed to init panel: {}", err); - None + // Log level submenu + let current_level = get_stored_log_level(app_handle); + let log_error = CheckMenuItem::with_id( + app_handle, + "log_error", + "Error", + true, + current_level == log::LevelFilter::Error, + None::<&str>, + )?; + let log_warn = CheckMenuItem::with_id( + app_handle, + "log_warn", + "Warn", + true, + current_level == log::LevelFilter::Warn, + None::<&str>, + )?; + let log_info = CheckMenuItem::with_id( + app_handle, + "log_info", + "Info", + true, + current_level == log::LevelFilter::Info, + None::<&str>, + )?; + let log_debug = CheckMenuItem::with_id( + app_handle, + "log_debug", + "Debug", + true, + current_level == log::LevelFilter::Debug, + None::<&str>, + )?; + let log_trace = CheckMenuItem::with_id( + app_handle, + "log_trace", + "Trace", + true, + current_level == log::LevelFilter::Trace, + None::<&str>, + )?; + + let log_level_submenu = Submenu::with_items( + app_handle, + "Debug Level", + true, + &[&log_error, &log_warn, &log_info, &log_debug, &log_trace], + )?; + + let separator = PredefinedMenuItem::separator(app_handle)?; + let separator2 = PredefinedMenuItem::separator(app_handle)?; + + let about = MenuItem::with_id(app_handle, "about", "About OpenUsage", true, None::<&str>)?; + let quit = MenuItem::with_id(app_handle, "quit", "Quit", true, None::<&str>)?; + + // Build provider items (max 5) + let mut provider_items: Vec> = vec![]; + for plugin in plugins.iter().take(5) { + let plugin_id = &plugin.manifest.id; + let plugin_name = &plugin.manifest.name; + + if let Some(output) = probe_results.get(plugin_id) { + // Find primary metric to display + let primary_line = output.lines.iter().find(|line| { + matches!(line, MetricLine::Progress { label, .. } if { + plugin.manifest.lines.iter().any(|manifest_line| { + manifest_line.line_type == "progress" + && manifest_line.label == *label + && manifest_line.primary_order.is_some() + }) + }) + }); + + let display_text = if let Some(MetricLine::Progress { used, limit, .. }) = primary_line + { + let percentage = if *limit > 0.0 { + ((*used / *limit) * 100.0) as i32 } else { - match $app_handle.get_webview_panel("main") { - Ok(panel) => Some(panel), - Err(err) => { - log::error!("Panel missing after init: {:?}", err); - None - } + 0 + }; + format!("{}: {}%", plugin_name, percentage) + } else if let Some(first_line) = output.lines.first() { + match first_line { + MetricLine::Progress { used, limit, .. } => { + let percentage = if *limit > 0.0 { + ((*used / *limit) * 100.0) as i32 + } else { + 0 + }; + format!("{}: {}%", plugin_name, percentage) + } + MetricLine::Text { value, .. } => { + format!("{}: {}", plugin_name, value) + } + MetricLine::Badge { text, .. } => { + format!("{}: {}", plugin_name, text) } } - } + } else { + plugin_name.clone() + }; + + let item = MenuItem::with_id( + app_handle, + format!("provider_{}", plugin_id), + display_text, + true, + None::<&str>, + )?; + provider_items.push(item); } + } + + // Build final menu based on how many provider items we have + // Use references to avoid move issues + let menu = match provider_items.len() { + 0 => Menu::with_items( + app_handle, + &[ + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + 1 => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + 2 => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &provider_items[1], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + 3 => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &provider_items[1], + &provider_items[2], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + 4 => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &provider_items[1], + &provider_items[2], + &provider_items[3], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + _ => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &provider_items[1], + &provider_items[2], + &provider_items[3], + &provider_items[4], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, }; + + Ok(menu) } -fn show_panel(app_handle: &AppHandle) { - if let Some(panel) = get_or_init_panel!(app_handle) { - panel.show_and_make_key(); +/// Update the tray menu with latest probe results +pub fn update_tray_menu(app_handle: &AppHandle) -> tauri::Result<()> { + let probe_results = { + let state = app_handle.state::>(); + if let Ok(app_state) = state.lock() { + app_state.latest_probe_results.clone() + } else { + HashMap::new() + } + }; + + let new_menu = build_tray_menu(app_handle, &probe_results)?; + + // Get the tray and update its menu + if let Some(tray) = app_handle.tray_by_id("tray") { + tray.set_menu(Some(new_menu))?; } + + Ok(()) } pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { - let tray_icon_path = app_handle - .path() - .resolve("icons/tray-icon.png", BaseDirectory::Resource)?; - let icon = Image::from_path(tray_icon_path)?; + // Platform-specific tray icon - Windows uses larger PNG, macOS uses template PNG + #[cfg(target_os = "windows")] + let icon_candidates = ["icons/64x64.png", "icons/icon.png"]; + + #[cfg(not(target_os = "windows"))] + let icon_candidates = ["icons/tray-icon.png", "icons/icon.png"]; + + // Try multiple icon locations (for dev mode compatibility) + let mut icon = None; + let mut last_error = None; + + // First try Resource directory (works in release builds) + for icon_path_str in &icon_candidates { + match app_handle + .path() + .resolve(icon_path_str, BaseDirectory::Resource) + { + Ok(path) => { + log::info!("Trying tray icon (Resource): {:?}", path); + match Image::from_path(&path) { + Ok(img) => { + log::info!("Tray icon loaded successfully from: {:?}", path); + icon = Some(img); + break; + } + Err(e) => { + log::warn!("Failed to load icon from {:?}: {}", path, e); + last_error = Some(e); + } + } + } + Err(e) => { + log::warn!("Failed to resolve icon path '{}': {}", icon_path_str, e); + last_error = Some(e); + } + } + } + + // If Resource didn't work, try App directory (dev mode fallback) + if icon.is_none() { + for icon_path_str in &icon_candidates { + match app_handle + .path() + .resolve(icon_path_str, BaseDirectory::AppLocalData) + { + Ok(path) => { + log::info!("Trying tray icon (AppLocalData): {:?}", path); + match Image::from_path(&path) { + Ok(img) => { + log::info!("Tray icon loaded successfully from: {:?}", path); + icon = Some(img); + break; + } + Err(e) => { + log::warn!("Failed to load icon from {:?}: {}", path, e); + last_error = Some(e); + } + } + } + Err(e) => { + log::warn!( + "Failed to resolve AppLocalData icon path '{}': {}", + icon_path_str, + e + ); + last_error = Some(e); + } + } + } + } + + let icon = match icon { + Some(img) => img, + None => { + log::error!("Could not load any tray icon. Last error: {:?}", last_error); + return Err(last_error.unwrap_or(tauri::Error::UnknownPath)); + } + }; // Load persisted log level let current_level = get_stored_log_level(app_handle); log::set_max_level(current_level); - let show_stats = MenuItem::with_id(app_handle, "show_stats", "Show Stats", true, None::<&str>)?; - let go_to_settings = MenuItem::with_id(app_handle, "go_to_settings", "Go to Settings", true, None::<&str>)?; - - // Log level submenu - clone items for use in event handler - let log_error = CheckMenuItem::with_id(app_handle, "log_error", "Error", true, current_level == log::LevelFilter::Error, None::<&str>)?; - let log_warn = CheckMenuItem::with_id(app_handle, "log_warn", "Warn", true, current_level == log::LevelFilter::Warn, None::<&str>)?; - let log_info = CheckMenuItem::with_id(app_handle, "log_info", "Info", true, current_level == log::LevelFilter::Info, None::<&str>)?; - let log_debug = CheckMenuItem::with_id(app_handle, "log_debug", "Debug", true, current_level == log::LevelFilter::Debug, None::<&str>)?; - let log_trace = CheckMenuItem::with_id(app_handle, "log_trace", "Trace", true, current_level == log::LevelFilter::Trace, None::<&str>)?; - let log_level_submenu = Submenu::with_items( - app_handle, - "Debug Level", - true, - &[&log_error, &log_warn, &log_info, &log_debug, &log_trace], - )?; - - // Clone for capture in event handler - let log_items = [ - (log_error.clone(), log::LevelFilter::Error), - (log_warn.clone(), log::LevelFilter::Warn), - (log_info.clone(), log::LevelFilter::Info), - (log_debug.clone(), log::LevelFilter::Debug), - (log_trace.clone(), log::LevelFilter::Trace), - ]; + // Build initial menu (empty probe results) + let menu = build_tray_menu(app_handle, &HashMap::new())?; - let separator = PredefinedMenuItem::separator(app_handle)?; - let about = MenuItem::with_id(app_handle, "about", "About OpenUsage", true, None::<&str>)?; - let quit = MenuItem::with_id(app_handle, "quit", "Quit", true, None::<&str>)?; - - let menu = Menu::with_items(app_handle, &[&show_stats, &go_to_settings, &log_level_submenu, &separator, &about, &quit])?; + // Platform-specific tray icon builder + #[cfg(target_os = "windows")] + let builder = TrayIconBuilder::with_id("tray") + .icon(icon) + .tooltip("OpenUsage") + .menu(&menu) + .show_menu_on_left_click(false); - TrayIconBuilder::with_id("tray") + #[cfg(not(target_os = "windows"))] + let builder = TrayIconBuilder::with_id("tray") .icon(icon) .icon_as_template(true) .tooltip("OpenUsage") .menu(&menu) - .show_menu_on_left_click(false) + .show_menu_on_left_click(false); + + builder .on_menu_event(move |app_handle, event| { log::debug!("tray menu: {}", event.id.as_ref()); match event.id.as_ref() { + id if id.starts_with("provider_") => { + // Provider item clicked - show stats and navigate to provider + let plugin_id = id.strip_prefix("provider_").unwrap_or(id); + let _ = WindowManager::show(app_handle); + let _ = app_handle.emit("tray:navigate", "home"); + let _ = app_handle.emit("tray:select-provider", plugin_id); + } "show_stats" => { - show_panel(app_handle); + let _ = WindowManager::show(app_handle); let _ = app_handle.emit("tray:navigate", "home"); } "go_to_settings" => { - show_panel(app_handle); + let _ = WindowManager::show(app_handle); let _ = app_handle.emit("tray:navigate", "settings"); } "about" => { - show_panel(app_handle); + let _ = WindowManager::show(app_handle); let _ = app_handle.emit("tray:show-about", ()); } "quit" => { @@ -149,10 +439,8 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { _ => unreachable!(), }; set_stored_log_level(app_handle, selected_level); - // Update all checkmarks - only the selected level should be checked - for (item, level) in &log_items { - let _ = item.set_checked(*level == selected_level); - } + // Update the menu to reflect new log level + let _ = update_tray_menu(app_handle); } _ => {} } @@ -165,20 +453,60 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { } = event { if button_state == MouseButtonState::Up { - let Some(panel) = get_or_init_panel!(app_handle) else { - return; - }; + #[cfg(target_os = "macos")] + { + // macOS: Use panel behavior + use tauri_nspanel::ManagerExt; + + let panel = match app_handle.get_webview_panel("main") { + Ok(p) => Some(p), + Err(_) => { + if let Err(err) = crate::panel::init(&app_handle) { + log::error!("Failed to init panel: {}", err); + None + } else { + app_handle.get_webview_panel("main").ok() + } + } + }; - if panel.is_visible() { - log::debug!("tray click: hiding panel"); - panel.hide(); - return; + if let Some(panel) = panel { + if panel.is_visible() { + log::debug!("tray click: hiding panel"); + panel.hide(); + return; + } + log::debug!("tray click: showing panel"); + panel.show_and_make_key(); + position_window_at_tray(app_handle, rect.position, rect.size); + } } - log::debug!("tray click: showing panel"); - // macOS quirk: must show window before positioning to another monitor - panel.show_and_make_key(); - position_panel_at_tray_icon(app_handle, rect.position, rect.size); + #[cfg(target_os = "windows")] + { + // Windows: Use regular window + let window = app_handle.get_webview_window("main"); + + if let Some(window) = window { + if window.is_visible().unwrap_or(false) { + log::debug!("tray click: hiding window"); + let _ = window.hide(); + return; + } + + log::debug!("tray click: showing window"); + + // Position window near tray icon + if let (tauri::Position::Physical(pos), tauri::Size::Physical(size)) = + (rect.position, rect.size) + { + let _ = position_window_at_tray(&app_handle, pos, size); + } + + let _ = window.show(); + let _ = window.set_focus(); + } + } } } }) @@ -186,3 +514,9 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { Ok(()) } + +/// Tauri command to update tray menu from frontend +#[tauri::command] +pub fn refresh_tray_menu(app_handle: tauri::AppHandle) -> Result<(), String> { + update_tray_menu(&app_handle).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/window_manager.rs b/src-tauri/src/window_manager.rs new file mode 100644 index 00000000..842d5439 --- /dev/null +++ b/src-tauri/src/window_manager.rs @@ -0,0 +1,168 @@ +use tauri::{AppHandle, Manager, PhysicalPosition}; + +#[cfg(target_os = "macos")] +use tauri::{Position, Size}; + +/// Platform-specific window manager +pub struct WindowManager; + +impl WindowManager { + /// Initialize the window for the current platform + pub fn init(app_handle: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + crate::panel::init(app_handle)?; + } + + #[cfg(target_os = "windows")] + { + setup_windows_window(app_handle)?; + } + + Ok(()) + } + + /// Show the window + pub fn show(app_handle: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.show_and_make_key(); + } else { + crate::panel::init(app_handle)?; + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.show_and_make_key(); + } + } + } + + #[cfg(not(target_os = "macos"))] + { + if let Some(window) = app_handle.get_webview_window("main") { + window.show()?; + window.set_focus()?; + } + } + + Ok(()) + } + + /// Hide the window + pub fn hide(app_handle: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.hide(); + } + } + + #[cfg(not(target_os = "macos"))] + { + if let Some(window) = app_handle.get_webview_window("main") { + window.hide()?; + } + } + + Ok(()) + } +} + +/// Set up Windows-specific window styles for transparency +#[cfg(target_os = "windows")] +pub fn setup_windows_window(app_handle: &AppHandle) -> tauri::Result<()> { + use tauri::Manager; + + if let Some(window) = app_handle.get_webview_window("main") { + // Disable WebView2 hardware acceleration to fix transparency issues + let _ = + window.eval("document.documentElement.style.setProperty('background', 'transparent')"); + + // Ensure window has no background + let _ = window.eval( + " + if (document.body) { + document.body.style.background = 'transparent'; + document.body.style.margin = '0'; + document.body.style.padding = '0'; + } + ", + ); + } + + Ok(()) +} + +/// Position the window at the tray icon location +#[cfg(target_os = "windows")] +pub fn position_window_at_tray( + app_handle: &AppHandle, + icon_position: PhysicalPosition, + icon_size: tauri::PhysicalSize, +) -> tauri::Result<()> { + use tauri::LogicalPosition; + + let window = app_handle + .get_webview_window("main") + .ok_or(tauri::Error::WindowNotFound)?; + + // Get window size + let window_size = window.outer_size()?; + let window_width = window_size.width as i32; + let window_height = window_size.height as i32; + + // Calculate monitor and scale factor + let monitors = window.available_monitors()?; + let mut target_monitor = None; + + for monitor in monitors { + let pos = monitor.position(); + let size = monitor.size(); + let x_in = icon_position.x >= pos.x && icon_position.x < pos.x + size.width as i32; + let y_in = icon_position.y >= pos.y && icon_position.y < pos.y + size.height as i32; + + if x_in && y_in { + target_monitor = Some(monitor); + break; + } + } + + let scale_factor = target_monitor + .as_ref() + .map(|m| m.scale_factor()) + .unwrap_or(1.0); + + // Calculate position: center horizontally above the tray icon + let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); + let window_x = icon_center_x - (window_width / 2); + let window_y = icon_position.y - window_height; + + // Clamp to monitor bounds + let final_x = if let Some(ref monitor) = target_monitor { + let monitor_x = monitor.position().x; + let monitor_width = monitor.size().width as i32; + window_x.clamp(monitor_x, monitor_x + monitor_width - window_width) + } else { + window_x.max(0) + }; + + let final_y = if let Some(ref monitor) = target_monitor { + let monitor_y = monitor.position().y; + monitor_y.max(window_y) + } else { + window_y.max(0) + }; + + // Convert to logical position + let logical_pos = + LogicalPosition::new(final_x as f64 / scale_factor, final_y as f64 / scale_factor); + + window.set_position(tauri::Position::Logical(logical_pos))?; + + Ok(()) +} + +/// macOS version delegates to existing panel implementation +#[cfg(target_os = "macos")] +pub fn position_window_at_tray(app_handle: &AppHandle, icon_position: Position, icon_size: Size) { + crate::panel::position_panel_at_tray_icon(app_handle, icon_position, icon_size); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0560e3fa..4abf145e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,9 @@ "resizable": false, "decorations": false, "transparent": true, - "visible": false + "visible": false, + "shadow": false, + "skipTaskbar": true } ], "security": { @@ -39,7 +41,8 @@ ], "resources": [ "resources/bundled_plugins/**/*", - "icons/tray-icon.png" + "icons/tray-icon.png", + "icons/64x64.png" ], "createUpdaterArtifacts": true }, diff --git a/src/App.tsx b/src/App.tsx index 650eb8c0..d050962f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,16 +3,17 @@ import { invoke, isTauri } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" import { getCurrentWindow, PhysicalSize, currentMonitor } from "@tauri-apps/api/window" import { getVersion } from "@tauri-apps/api/app" -import { resolveResource } from "@tauri-apps/api/path" import { TrayIcon } from "@tauri-apps/api/tray" +import { platform } from "@tauri-apps/plugin-os" import { SideNav, type ActiveView } from "@/components/side-nav" import { PanelFooter } from "@/components/panel-footer" import { OverviewPage } from "@/pages/overview" import { ProviderDetailPage } from "@/pages/provider-detail" import { SettingsPage } from "@/pages/settings" import type { PluginMeta, PluginOutput } from "@/lib/plugin-types" -import { getTrayIconSizePx, renderTrayBarsIcon } from "@/lib/tray-bars-icon" -import { getTrayPrimaryBars } from "@/lib/tray-primary-progress" +// Tray icon updates disabled - backend handles it +// import { getTrayIconSizePx, renderTrayBarsIcon } from "@/lib/tray-bars-icon" +// import { getTrayPrimaryBars } from "@/lib/tray-primary-progress" import { useProbeEvents } from "@/hooks/use-probe-events" import { useAppUpdate } from "@/hooks/use-app-update" import { @@ -74,6 +75,7 @@ function App() { const [themeMode, setThemeMode] = useState(DEFAULT_THEME_MODE) const [displayMode, setDisplayMode] = useState(DEFAULT_DISPLAY_MODE) const [trayIconStyle, setTrayIconStyle] = useState(DEFAULT_TRAY_ICON_STYLE) + const [isWindows, setIsWindows] = useState(false) const [trayShowPercentage, setTrayShowPercentage] = useState(DEFAULT_TRAY_SHOW_PERCENTAGE) const [maxPanelHeightPx, setMaxPanelHeightPx] = useState(null) const maxPanelHeightPxRef = useRef(null) @@ -82,11 +84,8 @@ function App() { const { updateStatus, triggerInstall } = useAppUpdate() const [showAbout, setShowAbout] = useState(false) + // Tray icon managed by backend - no frontend refs needed const trayRef = useRef(null) - const trayGaugeIconPathRef = useRef(null) - const trayUpdateTimerRef = useRef(null) - const trayUpdatePendingRef = useRef(false) - const [trayReady, setTrayReady] = useState(false) // Store state in refs so scheduleTrayIconUpdate can read current values without recreating the callback const pluginsMetaRef = useRef(pluginsMeta) @@ -102,112 +101,19 @@ function App() { useEffect(() => { trayIconStyleRef.current = trayIconStyle }, [trayIconStyle]) useEffect(() => { trayShowPercentageRef.current = trayShowPercentage }, [trayShowPercentage]) - // Fetch app version on mount + // Fetch app version and detect platform on mount useEffect(() => { getVersion().then(setAppVersion) - }, []) - - // Stable callback that reads from refs - never recreated, so debounce works correctly - const scheduleTrayIconUpdate = useCallback((_reason: "probe" | "settings" | "init", delayMs = 0) => { - if (trayUpdateTimerRef.current !== null) { - window.clearTimeout(trayUpdateTimerRef.current) - trayUpdateTimerRef.current = null + // Detect if Windows for arrow positioning + try { + const p = platform() + setIsWindows(p === 'windows') + } catch { + setIsWindows(false) } - - trayUpdateTimerRef.current = window.setTimeout(() => { - trayUpdateTimerRef.current = null - if (trayUpdatePendingRef.current) return - trayUpdatePendingRef.current = true - - const tray = trayRef.current - if (!tray) { - trayUpdatePendingRef.current = false - return - } - - const style = trayIconStyleRef.current - const maxBars = style === "bars" ? 4 : 1 - const bars = getTrayPrimaryBars({ - pluginsMeta: pluginsMetaRef.current, - pluginSettings: pluginSettingsRef.current, - pluginStates: pluginStatesRef.current, - maxBars, - displayMode: displayModeRef.current, - }) - - // 0 bars: revert to the packaged gauge tray icon. - if (bars.length === 0) { - const gaugePath = trayGaugeIconPathRef.current - if (gaugePath) { - Promise.all([ - tray.setIcon(gaugePath), - tray.setIconAsTemplate(true), - ]) - .catch((e) => { - console.error("Failed to restore tray gauge icon:", e) - }) - .finally(() => { - trayUpdatePendingRef.current = false - }) - } else { - trayUpdatePendingRef.current = false - } - return - } - - const percentageMandatory = isTrayPercentageMandatory(style) - - let percentText: string | undefined - if (percentageMandatory || trayShowPercentageRef.current) { - const firstFraction = bars[0]?.fraction - if (typeof firstFraction === "number" && Number.isFinite(firstFraction)) { - const clamped = Math.max(0, Math.min(1, firstFraction)) - const rounded = Math.round(clamped * 100) - percentText = `${rounded}%` - } - } - - if (style === "textOnly" && !percentText) { - const gaugePath = trayGaugeIconPathRef.current - if (gaugePath) { - Promise.all([ - tray.setIcon(gaugePath), - tray.setIconAsTemplate(true), - ]) - .catch((e) => { - console.error("Failed to restore tray gauge icon:", e) - }) - .finally(() => { - trayUpdatePendingRef.current = false - }) - } else { - trayUpdatePendingRef.current = false - } - return - } - - const sizePx = getTrayIconSizePx(window.devicePixelRatio) - const firstProviderId = bars[0]?.id - const providerIconUrl = - style === "provider" - ? pluginsMetaRef.current.find((plugin) => plugin.id === firstProviderId)?.iconUrl - : undefined - - renderTrayBarsIcon({ bars, sizePx, style, percentText, providerIconUrl }) - .then(async (img) => { - await tray.setIcon(img) - await tray.setIconAsTemplate(true) - }) - .catch((e) => { - console.error("Failed to update tray icon:", e) - }) - .finally(() => { - trayUpdatePendingRef.current = false - }) - }, delayMs) }, []) - // Initialize tray handle once (separate from tray updates) + // Initialize tray handle once - just get reference, don't update icon const trayInitializedRef = useRef(false) useEffect(() => { if (trayInitializedRef.current) return @@ -218,12 +124,6 @@ function App() { if (cancelled) return trayRef.current = tray trayInitializedRef.current = true - setTrayReady(true) - try { - trayGaugeIconPathRef.current = await resolveResource("icons/tray-icon.png") - } catch (e) { - console.error("Failed to resolve tray gauge icon resource:", e) - } } catch (e) { console.error("Failed to load tray icon handle:", e) } @@ -233,14 +133,13 @@ function App() { } }, []) - // Trigger tray update once tray + plugin metadata/settings are available. - // This prevents missing the first paint if probe results arrive before the tray handle resolves. - useEffect(() => { - if (!trayReady) return - if (!pluginSettings) return - if (pluginsMeta.length === 0) return - scheduleTrayIconUpdate("init", 0) - }, [pluginsMeta.length, pluginSettings, scheduleTrayIconUpdate, trayReady]) + // Tray icon updates disabled - backend sets icon once on startup + const scheduleTrayIconUpdate = useCallback((_reason: "probe" | "settings" | "init", _delayMs = 0) => { + // Icon updates disabled to prevent frontend from overriding backend icon + }, []) + + // Don't update tray icon on init - backend already set the correct icon + // Only update when we have actual probe results (handled by handleProbeResult) const displayPlugins = useMemo(() => { @@ -827,11 +726,16 @@ function App() { } return ( -
-
+
+ {/* macOS: Arrow at top pointing up | Windows: Arrow at bottom pointing down */} + {!isWindows &&
}
+ {/* Windows: Arrow at bottom pointing down toward taskbar */} + {isWindows &&
}
); } diff --git a/src/index.css b/src/index.css index ec8200cc..d5968ebd 100644 --- a/src/index.css +++ b/src/index.css @@ -144,10 +144,20 @@ html, body, #root { - background: transparent; + background: transparent !important; overflow: hidden; margin: 0; padding: 0; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +/* Windows-specific fix for WebView2 white border */ +webview { + background: transparent !important; + border: none !important; + outline: none !important; } /* Hide scrollbar track globally */ @@ -158,7 +168,7 @@ body, display: none; /* Chrome / Safari / WebKit */ } -/* Arrow pointing up toward the tray icon */ +/* Arrow pointing up toward the tray icon (macOS - top menu bar) */ .tray-arrow { width: 0; height: 0; @@ -180,6 +190,28 @@ body, border-bottom: 6px solid var(--card); } +/* Arrow pointing down toward the tray icon (Windows - bottom taskbar) */ +.tray-arrow-down { + width: 0; + height: 0; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-top: 7px solid var(--border); + position: relative; + flex-shrink: 0; +} +.tray-arrow-down::after { + content: ""; + position: absolute; + left: -6px; + bottom: 1.5px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--card); +} + /* Border beam for the update button */ .update-border-beam { position: relative; From df6fd5c2fb5a68518d44af8504b77c6e0266837b Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Mon, 9 Feb 2026 13:45:05 -0500 Subject: [PATCH 02/16] feat: stabilize Windows UX for testers --- docs/breadcrumbs.md | 11 + docs/choices.md | 75 +++ docs/specs/2026-02-09-windows-dev.md | 18 + docs/specs/2026-02-09-windows-os-gating.md | 22 + .../2026-02-09-windows-plugin-porting.md | 26 + plugins/antigravity/plugin.js | 8 +- plugins/antigravity/plugin.json | 1 + plugins/claude/plugin.js | 99 +++- plugins/claude/plugin.json | 1 + plugins/codex/plugin.js | 60 +- plugins/codex/plugin.json | 1 + plugins/copilot/plugin.js | 32 +- plugins/copilot/plugin.json | 1 + plugins/cursor/plugin.js | 74 ++- plugins/cursor/plugin.json | 1 + plugins/mock/plugin.json | 1 + plugins/windsurf/plugin.js | 63 +- plugins/windsurf/plugin.json | 1 + src-tauri/src/lib.rs | 40 +- src-tauri/src/plugin_engine/host_api.rs | 543 ++++++++++++------ src-tauri/src/window_manager.rs | 192 ++++++- src/App.tsx | 207 +++++-- src/index.css | 44 ++ 23 files changed, 1236 insertions(+), 285 deletions(-) create mode 100644 docs/breadcrumbs.md create mode 100644 docs/choices.md create mode 100644 docs/specs/2026-02-09-windows-dev.md create mode 100644 docs/specs/2026-02-09-windows-os-gating.md create mode 100644 docs/specs/2026-02-09-windows-plugin-porting.md diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md new file mode 100644 index 00000000..228ac218 --- /dev/null +++ b/docs/breadcrumbs.md @@ -0,0 +1,11 @@ +# Breadcrumbs + +## 2026-02-09 + +- Windows dev: align AppState plugin/probe types with LoadedPlugin + PluginOutput, and persist arrow offset for tray alignment fallback. +- Arrow alignment: account for panel horizontal padding when positioning tray arrow. +- Side taskbar: compute arrow offset on Y axis and render left/right arrows. +- Side taskbar: use work area bounds to avoid overlapping the taskbar. +- Windows plugin scan: identified OS path/keychain blockers per plugin. +- Windows plugins: add Windows path candidates for Codex/Claude/Cursor/Windsurf and guard keychain usage to macOS. +- Windows OS gating: enable windows in plugin manifests and add actionable missing-path errors for testers. diff --git a/docs/choices.md b/docs/choices.md new file mode 100644 index 00000000..06d5e2bc --- /dev/null +++ b/docs/choices.md @@ -0,0 +1,75 @@ +# Design Choices + +This document records opinionated defaults chosen during development. + +## 2026-02-07 + +### Zen Plugin: No Billing API Available + +**Context:** The OpenCode Zen provider plugin needs to display usage/billing data. + +**Finding:** After analyzing the OpenCode source code (`packages/console/`), the billing data is only exposed via SolidStart SSR routes (`"use server"`) which require web session authentication (cookies), not API key authentication. + +**Decision:** The Zen plugin currently: +1. Validates API key by calling `GET /zen/v1/models` +2. Shows "Connected" status with model count +3. Directs users to `opencode.ai/billing` for usage data + +**Alternative considered:** Scraping the web console - rejected as fragile and potentially against ToS. + +**Next step:** Request a public usage API from OpenCode team via GitHub Discussions. + +**Technical details:** +- Balance stored in micro-cents (`/ 100_000_000` for dollars) +- Key fields: `balance`, `monthlyUsage`, `monthlyLimit`, `reload`, `reloadAmount`, `reloadTrigger` +- Source: `packages/console/app/src/routes/workspace/common.tsx:93-120` + +## 2026-02-09 + +### Persist Arrow Offset in AppState + +**Context:** The frontend sometimes misses the `window:positioned` event and falls back to centered arrow placement. + +**Decision:** Persist `last_arrow_offset` alongside `last_taskbar_position` in `AppState`, so `get_arrow_offset` can restore the correct tray arrow alignment on focus. + +### Arrow Offset Accounts for Panel Padding + +**Context:** Arrow offset is computed from window left edge, but the arrow is rendered inside a container with horizontal padding (`px-4`). + +**Decision:** Subtract 16px container padding when applying `marginLeft` so the arrow tip aligns with the clicked tray icon. + +### Side Taskbar Arrow Uses Y Offset + +**Context:** When the Windows taskbar is on the left or right, the arrow should align using Y offset and render from the side. + +**Decision:** Compute arrow offset using icon center Y for left/right taskbars, and render side arrows with `marginTop` adjusted by vertical padding. + +### Use Work Area For Side Positioning + +**Context:** On Windows with side taskbars, using full monitor bounds causes the panel to overlap the taskbar. + +**Decision:** Use `monitor.work_area()` bounds for left/right window X positioning and clamping so the panel sits fully in the available work area. + +### Windows Plugin Paths: Use Common AppData Candidates + +**Context:** There is no authoritative published path for Codex/Claude auth files or Cursor/Windsurf `state.vscdb` on Windows. + +**Decision:** Probe common Windows locations based on `APPDATA`, `LOCALAPPDATA`, and `USERPROFILE` (e.g., `APPDATA\Cursor\User\globalStorage\state.vscdb`, `USERPROFILE\.claude\.credentials.json`, `APPDATA\codex\auth.json`) and use the first existing path; otherwise fall back to the first candidate. + +### Keychain Guard On Windows + +**Context:** The host keychain API is only supported on macOS and throws on Windows. + +**Decision:** Only call keychain APIs when `ctx.app.platform === "macos"`; use file-based auth paths otherwise. + +### OS Gating For Windows Testers + +**Context:** We need Windows testers to run plugins while we validate paths and auth locations. + +**Decision:** Add `os` to plugin manifests and enable Windows for all user-facing plugins so testers can validate behavior; keep errors explicit when paths are missing. + +### Windows Error Messages Include Expected Paths + +**Context:** Testers need actionable path hints when auth/state files cannot be found. + +**Decision:** Provide Windows-specific error strings that mention likely file locations (AppData/UserProfile) and ask testers to report actual paths. diff --git a/docs/specs/2026-02-09-windows-dev.md b/docs/specs/2026-02-09-windows-dev.md new file mode 100644 index 00000000..ce549709 --- /dev/null +++ b/docs/specs/2026-02-09-windows-dev.md @@ -0,0 +1,18 @@ +# Windows Dev Fixes (2026-02-09) + +## Goals + +- Fix Windows dev build errors in `src-tauri` caused by mismatched plugin state types. +- Ensure tray arrow can align to icon even if `window:positioned` event is missed. + +## Non-Goals + +- No new UI behavior changes beyond arrow offset alignment. +- No plugin runtime changes. + +## Changes + +- Store `LoadedPlugin` in `AppState` and store probe results as `PluginOutput`. +- Persist `last_arrow_offset` in window positioning so `get_arrow_offset` is reliable. +- Use Y-axis arrow offsets and side arrows for left/right taskbars. +- Use work area bounds for side-taskbar window positioning/clamping. diff --git a/docs/specs/2026-02-09-windows-os-gating.md b/docs/specs/2026-02-09-windows-os-gating.md new file mode 100644 index 00000000..2eb26cac --- /dev/null +++ b/docs/specs/2026-02-09-windows-os-gating.md @@ -0,0 +1,22 @@ +# Windows OS Gating + Error Reporting (2026-02-09) + +## Goals + +- Declare OS support in plugin manifests so Windows can load targeted plugins. +- Provide clear, actionable error messages for Windows testers when paths are missing. + +## Non-Goals + +- Prove exact Windows paths for every app. +- Add new runtime APIs or change plugin protocol. + +## Plan + +- Add `os` to plugin.json for each plugin. +- Improve Windows-specific error messages in Codex, Claude, Cursor, and Windsurf. +- Keep keychain access macOS-only and rely on file-based auth for Windows. + +## Notes + +- Windows is enabled for all user-facing plugins to allow tester validation. +- Errors include expected Windows file locations and request testers report actual paths. diff --git a/docs/specs/2026-02-09-windows-plugin-porting.md b/docs/specs/2026-02-09-windows-plugin-porting.md new file mode 100644 index 00000000..99c0aa9b --- /dev/null +++ b/docs/specs/2026-02-09-windows-plugin-porting.md @@ -0,0 +1,26 @@ +# Windows Plugin Porting Scan (2026-02-09) + +## Goals + +- Identify Windows blockers for remaining plugins. +- Outline path normalization or keychain requirements per plugin. + +## Non-Goals + +- Implement Windows support for each plugin. +- Modify plugin runtime behavior. + +## Findings + +- antigravity: already Windows-aware for LS process name; no path blockers. +- codex: auth file now checks Windows candidates (`APPDATA`, `LOCALAPPDATA`, `USERPROFILE`) before Unix paths. +- claude: credentials file now checks Windows candidates (`USERPROFILE`, `APPDATA`, `LOCALAPPDATA`) before `~/.claude`. +- copilot: keychain access is now guarded to macOS; Windows relies on `auth.json` fallback. +- cursor: now checks Windows candidates for `globalStorage/state.vscdb` plus Linux/macOS defaults. +- windsurf: now checks Windows candidates for `globalStorage/state.vscdb` plus Linux/macOS defaults; Windows LS process name already handled. +- mock: test-only. + +## Next Steps + +- Validate actual on-disk paths for Codex/Claude/Cursor/Windsurf on Windows installs. +- Add Windows OS gating in plugin.json if needed and surface user-friendly errors when paths are missing. diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 69a71509..6cf48a44 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -4,8 +4,12 @@ // --- LS discovery --- function discoverLs(ctx) { + var processName = ctx.app.platform === "windows" + ? "language_server_windows_x64.exe" + : "language_server_macos" + return ctx.host.ls.discover({ - processName: "language_server_macos", + processName: processName, markers: ["antigravity"], csrfFlag: "--csrf_token", portFlag: "--extension_server_port", @@ -28,7 +32,7 @@ extensionVersion: "unknown", ide: "antigravity", ideVersion: "unknown", - os: "macos", + os: ctx.app.platform === "windows" ? "windows" : "macos", }, }, }), diff --git a/plugins/antigravity/plugin.json b/plugins/antigravity/plugin.json index eb7b4880..dfe91e32 100644 --- a/plugins/antigravity/plugin.json +++ b/plugins/antigravity/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#4285F4", + "os": ["macos", "windows"], "lines": [ { "type": "progress", "label": "Gemini 3 Pro", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Gemini 3 Flash", "scope": "overview" }, diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index a719ef01..bf2c8413 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -123,9 +123,10 @@ function loadCredentials(ctx) { // Try file first - if (ctx.host.fs.exists(CRED_FILE)) { + const credPath = getCredentialsPath(ctx) + if (credPath && ctx.host.fs.exists(credPath)) { try { - const text = ctx.host.fs.readText(CRED_FILE) + const text = ctx.host.fs.readText(credPath) const parsed = tryParseCredentialJSON(ctx, text) if (parsed) { const oauth = parsed.claudeAiOauth @@ -141,21 +142,23 @@ } // Try keychain fallback - try { - const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE) - if (keychainValue) { - const parsed = tryParseCredentialJSON(ctx, keychainValue) - if (parsed) { - const oauth = parsed.claudeAiOauth - if (oauth && oauth.accessToken) { - ctx.host.log.info("credentials loaded from keychain") - return { oauth, source: "keychain", fullData: parsed } + if (isKeychainAvailable(ctx)) { + try { + const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE) + if (keychainValue) { + const parsed = tryParseCredentialJSON(ctx, keychainValue) + if (parsed) { + const oauth = parsed.claudeAiOauth + if (oauth && oauth.accessToken) { + ctx.host.log.info("credentials loaded from keychain") + return { oauth, source: "keychain", fullData: parsed } + } } + ctx.host.log.warn("keychain has data but no valid oauth") } - ctx.host.log.warn("keychain has data but no valid oauth") + } catch (e) { + ctx.host.log.info("keychain read failed (may not exist): " + String(e)) } - } catch (e) { - ctx.host.log.info("keychain read failed (may not exist): " + String(e)) } ctx.host.log.warn("no credentials found") @@ -168,13 +171,22 @@ const text = JSON.stringify(fullData) if (source === "file") { try { - ctx.host.fs.writeText(CRED_FILE, text) + const credPath = getCredentialsPath(ctx) + if (credPath) { + ctx.host.fs.writeText(credPath, text) + } else { + ctx.host.log.error("Failed to resolve Claude credentials path") + } } catch (e) { ctx.host.log.error("Failed to write Claude credentials file: " + String(e)) } } else if (source === "keychain") { try { - ctx.host.keychain.writeGenericPassword(KEYCHAIN_SERVICE, text) + if (isKeychainAvailable(ctx)) { + ctx.host.keychain.writeGenericPassword(KEYCHAIN_SERVICE, text) + } else { + ctx.host.log.error("Keychain not available on this platform") + } } catch (e) { ctx.host.log.error("Failed to write Claude credentials keychain: " + String(e)) } @@ -276,6 +288,13 @@ const creds = loadCredentials(ctx) if (!creds || !creds.oauth || !creds.oauth.accessToken || !creds.oauth.accessToken.trim()) { ctx.host.log.error("probe failed: not logged in") + if (ctx.app && ctx.app.platform === "windows") { + const candidates = getWindowsCredentialCandidates(ctx) + const preview = candidates.length > 0 + ? candidates.slice(0, 3).join(", ") + : "%USERPROFILE%\\.claude\\.credentials.json" + throw "Claude credentials not found on Windows. Expected one of: " + preview + ". Run `claude` to authenticate or report the actual path." + } throw "Not logged in. Run `claude` to authenticate." } @@ -402,3 +421,51 @@ globalThis.__openusage_plugin = { id: "claude", probe } })() + function getEnv(ctx, name) { + try { + if (!ctx.host.env || typeof ctx.host.env.get !== "function") return null + const value = ctx.host.env.get(name) + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed || null + } catch (e) { + ctx.host.log.warn(name + " read failed: " + String(e)) + return null + } + } + + function getUserProfile(ctx) { + const userProfile = getEnv(ctx, "USERPROFILE") + if (userProfile) return userProfile + const homeDrive = getEnv(ctx, "HOMEDRIVE") + const homePath = getEnv(ctx, "HOMEPATH") + if (homeDrive && homePath) return homeDrive + homePath + return null + } + + function getCredentialsPath(ctx) { + if (ctx.app && ctx.app.platform === "windows") { + const candidates = getWindowsCredentialCandidates(ctx) + for (const path of candidates) { + if (ctx.host.fs.exists(path)) return path + } + + if (candidates.length > 0) return candidates[0] + } + + return CRED_FILE + } + + function getWindowsCredentialCandidates(ctx) { + const appData = getEnv(ctx, "APPDATA") + const localAppData = getEnv(ctx, "LOCALAPPDATA") + const userProfile = getUserProfile(ctx) + const candidates = [] + if (userProfile) candidates.push(userProfile + "\\.claude\\.credentials.json") + if (appData) candidates.push(appData + "\\Claude\\.credentials.json") + if (localAppData) candidates.push(localAppData + "\\Claude\\.credentials.json") + return candidates + } + function isKeychainAvailable(ctx) { + return ctx.app && ctx.app.platform === "macos" + } diff --git a/plugins/claude/plugin.json b/plugins/claude/plugin.json index baea5e06..c8455e74 100644 --- a/plugins/claude/plugin.json +++ b/plugins/claude/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#DE7356", + "os": ["macos", "windows"], "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Weekly", "scope": "overview" }, diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index 4ad8ca47..94a5a1ec 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -6,32 +6,65 @@ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" const REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000 - function joinPath(base, leaf) { - return base.replace(/[\\/]+$/, "") + "/" + leaf + function joinPath(base, leaf, separator) { + const sep = separator || "/" + return base.replace(/[\\/]+$/, "") + sep + leaf } - function readCodexHome(ctx) { - if (!ctx.host.env || typeof ctx.host.env.get !== "function") { - return null - } - + function getEnv(ctx, name) { try { - const value = ctx.host.env.get("CODEX_HOME") + if (!ctx.host.env || typeof ctx.host.env.get !== "function") return null + const value = ctx.host.env.get(name) if (typeof value !== "string") return null const trimmed = value.trim() return trimmed || null } catch (e) { - ctx.host.log.warn("CODEX_HOME read failed: " + String(e)) + ctx.host.log.warn(name + " read failed: " + String(e)) return null } } + function getUserProfile(ctx) { + const userProfile = getEnv(ctx, "USERPROFILE") + if (userProfile) return userProfile + const homeDrive = getEnv(ctx, "HOMEDRIVE") + const homePath = getEnv(ctx, "HOMEPATH") + if (homeDrive && homePath) return homeDrive + homePath + return null + } + + function getWindowsAuthPaths(ctx) { + const appData = getEnv(ctx, "APPDATA") + const localAppData = getEnv(ctx, "LOCALAPPDATA") + const userProfile = getUserProfile(ctx) + const basePaths = [] + if (appData) basePaths.push(joinPath(appData, "codex", "\\")) + if (localAppData) basePaths.push(joinPath(localAppData, "codex", "\\")) + if (userProfile) { + basePaths.push(joinPath(userProfile, ".codex", "\\")) + basePaths.push(joinPath(userProfile, ".config\\codex", "\\")) + } + return basePaths.map((base) => joinPath(base, AUTH_FILE, "\\")) + } + + function readCodexHome(ctx) { + return getEnv(ctx, "CODEX_HOME") + } + function resolveAuthPath(ctx) { const codexHome = readCodexHome(ctx) // If CODEX_HOME is set, use it if (codexHome) { - return joinPath(codexHome, AUTH_FILE) + const sep = codexHome.includes("\\") ? "\\" : "/" + return joinPath(codexHome, AUTH_FILE, sep) + } + + if (ctx.app && ctx.app.platform === "windows") { + const windowsPaths = getWindowsAuthPaths(ctx) + for (const authPath of windowsPaths) { + if (ctx.host.fs.exists(authPath)) return authPath + } } // Otherwise, return the first existing auth file path @@ -194,6 +227,13 @@ const authState = loadAuth(ctx) if (!authState || !authState.auth) { ctx.host.log.error("probe failed: not logged in") + if (ctx.app && ctx.app.platform === "windows") { + const candidates = getWindowsAuthPaths(ctx) + const preview = candidates.length > 0 + ? candidates.slice(0, 3).join(", ") + : "%USERPROFILE%\\.codex\\auth.json" + throw "Codex auth file not found on Windows. Expected one of: " + preview + ". Run `codex` to authenticate or report the actual path." + } throw "Not logged in. Run `codex` to authenticate." } const auth = authState.auth diff --git a/plugins/codex/plugin.json b/plugins/codex/plugin.json index 27a0057b..9b753989 100644 --- a/plugins/codex/plugin.json +++ b/plugins/codex/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#74AA9C", + "os": ["macos", "windows"], "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Weekly", "scope": "overview" }, diff --git a/plugins/copilot/plugin.js b/plugins/copilot/plugin.js index 3d67f489..fd81efcb 100644 --- a/plugins/copilot/plugin.js +++ b/plugins/copilot/plugin.js @@ -3,6 +3,10 @@ const GH_KEYCHAIN_SERVICE = "gh:github.com"; const USAGE_URL = "https://api.github.com/copilot_internal/user"; + function isKeychainAvailable(ctx) { + return ctx.app && ctx.app.platform === "macos"; + } + function readJson(ctx, path) { try { if (!ctx.host.fs.exists(path)) return null; @@ -23,27 +27,32 @@ } function saveToken(ctx, token) { - try { - ctx.host.keychain.writeGenericPassword( - KEYCHAIN_SERVICE, - JSON.stringify({ token: token }), - ); - } catch (e) { - ctx.host.log.warn("keychain write failed: " + String(e)); + if (isKeychainAvailable(ctx)) { + try { + ctx.host.keychain.writeGenericPassword( + KEYCHAIN_SERVICE, + JSON.stringify({ token: token }), + ); + } catch (e) { + ctx.host.log.warn("keychain write failed: " + String(e)); + } } writeJson(ctx, ctx.app.pluginDataDir + "/auth.json", { token: token }); } function clearCachedToken(ctx) { - try { - ctx.host.keychain.deleteGenericPassword(KEYCHAIN_SERVICE); - } catch (e) { - ctx.host.log.info("keychain delete failed: " + String(e)); + if (isKeychainAvailable(ctx)) { + try { + ctx.host.keychain.deleteGenericPassword(KEYCHAIN_SERVICE); + } catch (e) { + ctx.host.log.info("keychain delete failed: " + String(e)); + } } writeJson(ctx, ctx.app.pluginDataDir + "/auth.json", null); } function loadTokenFromKeychain(ctx) { + if (!isKeychainAvailable(ctx)) return null; try { const raw = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE); if (raw) { @@ -60,6 +69,7 @@ } function loadTokenFromGhCli(ctx) { + if (!isKeychainAvailable(ctx)) return null; try { const raw = ctx.host.keychain.readGenericPassword(GH_KEYCHAIN_SERVICE); if (raw) { diff --git a/plugins/copilot/plugin.json b/plugins/copilot/plugin.json index 2c322139..30bc2380 100644 --- a/plugins/copilot/plugin.json +++ b/plugins/copilot/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#A855F7", + "os": ["macos", "windows"], "lines": [ { "type": "progress", "label": "Premium", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Chat", "scope": "overview", "primaryOrder": 2 }, diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 40fb84d1..883a9838 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -1,6 +1,7 @@ (function () { - const STATE_DB = + const MAC_STATE_DB = "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + const LINUX_STATE_DB = "~/.config/Cursor/User/globalStorage/state.vscdb" const BASE_URL = "https://api2.cursor.sh" const USAGE_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage" const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo" @@ -9,11 +10,55 @@ const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration - function readStateValue(ctx, key) { + function joinPath(base, leaf, separator) { + if (!base) return leaf + if (base.endsWith("/") || base.endsWith("\\")) return base + leaf + return base + separator + leaf + } + + function getEnv(ctx, name) { try { + return ctx.host.env.get(name) + } catch { + return null + } + } + + function getStateDbPath(ctx) { + if (ctx.app.platform === "windows") { + const appData = getEnv(ctx, "APPDATA") + const localAppData = getEnv(ctx, "LOCALAPPDATA") + const userProfile = getEnv(ctx, "USERPROFILE") + const candidates = [] + if (appData) candidates.push(joinPath(appData, "Cursor\\User", "\\")) + if (localAppData) candidates.push(joinPath(localAppData, "Cursor\\User", "\\")) + if (userProfile) { + candidates.push(joinPath(userProfile, "AppData\\Roaming\\Cursor\\User", "\\")) + candidates.push(joinPath(userProfile, "AppData\\Local\\Cursor\\User", "\\")) + } + for (const base of candidates) { + const dbPath = joinPath(base, "globalStorage\\state.vscdb", "\\") + if (ctx.host.fs.exists(dbPath)) return dbPath + } + if (candidates.length > 0) { + return joinPath(candidates[0], "globalStorage\\state.vscdb", "\\") + } + return null + } + + if (ctx.app.platform === "linux") return LINUX_STATE_DB + return MAC_STATE_DB + } + + function readStateValueFromDb(ctx, stateDb, key) { + try { + if (!stateDb) { + ctx.host.log.warn("state db path not found for Cursor") + return null + } const sql = "SELECT value FROM ItemTable WHERE key = '" + key + "' LIMIT 1;" - const json = ctx.host.sqlite.query(STATE_DB, sql) + const json = ctx.host.sqlite.query(stateDb, sql) const rows = ctx.util.tryParseJson(json) if (!Array.isArray(rows)) { throw new Error("sqlite returned invalid json") @@ -27,8 +72,18 @@ return null } + function readStateValue(ctx, key) { + const stateDb = getStateDbPath(ctx) + return readStateValueFromDb(ctx, stateDb, key) + } + function writeStateValue(ctx, key, value) { try { + const stateDb = getStateDbPath(ctx) + if (!stateDb) { + ctx.host.log.warn("state db path not found for Cursor") + return false + } // Escape single quotes in value for SQL const escaped = String(value).replace(/'/g, "''") const sql = @@ -37,7 +92,7 @@ "', '" + escaped + "');" - ctx.host.sqlite.exec(STATE_DB, sql) + ctx.host.sqlite.exec(stateDb, sql) return true } catch (e) { ctx.host.log.warn("sqlite write failed for " + key + ": " + String(e)) @@ -144,8 +199,15 @@ } function probe(ctx) { - let accessToken = readStateValue(ctx, "cursorAuth/accessToken") - const refreshTokenValue = readStateValue(ctx, "cursorAuth/refreshToken") + const stateDb = getStateDbPath(ctx) + if (ctx.app.platform === "windows") { + if (!stateDb || !ctx.host.fs.exists(stateDb)) { + throw "Cursor data store not found on Windows. Expected %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb (or Local). Please report the actual path." + } + } + + let accessToken = readStateValueFromDb(ctx, stateDb, "cursorAuth/accessToken") + const refreshTokenValue = readStateValueFromDb(ctx, stateDb, "cursorAuth/refreshToken") if (!accessToken && !refreshTokenValue) { ctx.host.log.error("probe failed: no access or refresh token in sqlite") diff --git a/plugins/cursor/plugin.json b/plugins/cursor/plugin.json index a8b2fab9..f89eb1c2 100644 --- a/plugins/cursor/plugin.json +++ b/plugins/cursor/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#000000", + "os": ["macos", "windows"], "lines": [ { "type": "progress", "label": "Credits", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Plan usage", "scope": "overview", "primaryOrder": 2 }, diff --git a/plugins/mock/plugin.json b/plugins/mock/plugin.json index aab922f0..4795073b 100644 --- a/plugins/mock/plugin.json +++ b/plugins/mock/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#EF4444", + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Ahead pace", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "On Track pace", "scope": "overview", "primaryOrder": 2 }, diff --git a/plugins/windsurf/plugin.js b/plugins/windsurf/plugin.js index a812cf57..0857ebc9 100644 --- a/plugins/windsurf/plugin.js +++ b/plugins/windsurf/plugin.js @@ -1,12 +1,17 @@ (function () { var LS_SERVICE = "exa.language_server_pb.LanguageServerService" - var STATE_DB = "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb" + var MAC_STATE_DB = "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb" + var LINUX_STATE_DB = "~/.config/Windsurf/User/globalStorage/state.vscdb" // --- LS discovery --- function discoverLs(ctx) { + var processName = ctx.app.platform === "windows" + ? "language_server_windows_x64.exe" + : "language_server_macos" + return ctx.host.ls.discover({ - processName: "language_server_macos", + processName: processName, markers: ["windsurf"], csrfFlag: "--csrf_token", portFlag: "--extension_server_port", @@ -16,8 +21,13 @@ function loadApiKey(ctx) { try { + var stateDb = getStateDbPath(ctx) + if (!stateDb) { + ctx.host.log.warn("state db path not found for Windsurf") + return null + } var rows = ctx.host.sqlite.query( - STATE_DB, + stateDb, "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus' LIMIT 1" ) var parsed = ctx.util.tryParseJson(rows) @@ -47,7 +57,7 @@ extensionVersion: "unknown", ide: "windsurf", ideVersion: "unknown", - os: "macos", + os: ctx.app.platform === "windows" ? "windows" : "macos", }, }, }), @@ -191,6 +201,12 @@ // --- Probe --- function probe(ctx) { + if (ctx.app.platform === "windows") { + var stateDb = getStateDbPath(ctx) + if (!stateDb || !ctx.host.fs.exists(stateDb)) { + throw "Windsurf data store not found on Windows. Expected %APPDATA%\\Windsurf\\User\\globalStorage\\state.vscdb (or Local). Please report the actual path." + } + } var result = probeViaLs(ctx) if (result) return result @@ -199,3 +215,42 @@ globalThis.__openusage_plugin = { id: "windsurf", probe: probe } })() + function joinPath(base, leaf, separator) { + if (!base) return leaf + if (base.endsWith("/") || base.endsWith("\\")) return base + leaf + return base + separator + leaf + } + + function getEnv(ctx, name) { + try { + return ctx.host.env.get(name) + } catch (e) { + return null + } + } + + function getStateDbPath(ctx) { + if (ctx.app.platform === "windows") { + var appData = getEnv(ctx, "APPDATA") + var localAppData = getEnv(ctx, "LOCALAPPDATA") + var userProfile = getEnv(ctx, "USERPROFILE") + var candidates = [] + if (appData) candidates.push(joinPath(appData, "Windsurf\\User", "\\")) + if (localAppData) candidates.push(joinPath(localAppData, "Windsurf\\User", "\\")) + if (userProfile) { + candidates.push(joinPath(userProfile, "AppData\\Roaming\\Windsurf\\User", "\\")) + candidates.push(joinPath(userProfile, "AppData\\Local\\Windsurf\\User", "\\")) + } + for (var i = 0; i < candidates.length; i++) { + var dbPath = joinPath(candidates[i], "globalStorage\\state.vscdb", "\\") + if (ctx.host.fs.exists(dbPath)) return dbPath + } + if (candidates.length > 0) { + return joinPath(candidates[0], "globalStorage\\state.vscdb", "\\") + } + return null + } + + if (ctx.app.platform === "linux") return LINUX_STATE_DB + return MAC_STATE_DB + } diff --git a/plugins/windsurf/plugin.json b/plugins/windsurf/plugin.json index 6c83aa05..8d3cb997 100644 --- a/plugins/windsurf/plugin.json +++ b/plugins/windsurf/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#111111", + "os": ["macos", "windows"], "lines": [ { "type": "progress", "label": "Prompt credits", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Flex credits", "scope": "overview" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06d03d85..94681b67 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,22 +10,43 @@ mod webkit_config; use std::collections::{HashMap, HashSet}; use tauri_plugin_aptabase::EventTracker; -use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use serde::Serialize; -use tauri::{Emitter, Manager}; +use tauri::{Emitter, Manager, State}; use tauri_plugin_log::{Target, TargetKind}; use uuid::Uuid; +use crate::plugin_engine::manifest::LoadedPlugin; +use crate::plugin_engine::runtime::PluginOutput; +use crate::window_manager::TaskbarPosition; + pub struct AppState { - pub plugins: Vec, - pub app_data_dir: PathBuf, + pub plugins: Vec, + pub app_data_dir: std::path::PathBuf, pub app_version: String, - pub latest_probe_results: std::collections::HashMap, + pub latest_probe_results: std::collections::HashMap, + pub last_taskbar_position: Option, + pub last_arrow_offset: Option, +} + +#[tauri::command] +fn get_taskbar_position(state: State<'_, Mutex>) -> Option { + state.lock().unwrap().last_taskbar_position.as_ref().map(|p| match p { + TaskbarPosition::Top => "top", + TaskbarPosition::Bottom => "bottom", + TaskbarPosition::Left => "left", + TaskbarPosition::Right => "right", + }.to_string()) } +#[tauri::command] +fn get_arrow_offset(state: State<'_, Mutex>) -> Option { + state.lock().unwrap().last_arrow_offset +} + + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PluginMeta { @@ -78,6 +99,8 @@ fn hide_panel(app_handle: tauri::AppHandle) { window_manager::WindowManager::hide(&app_handle).expect("Failed to hide window"); } + + #[tauri::command] async fn start_probe_batch( app_handle: tauri::AppHandle, @@ -107,7 +130,7 @@ async fn start_probe_batch( let selected_plugins = match plugin_ids { Some(ids) => { - let mut by_id: HashMap = plugins + let mut by_id: HashMap = plugins .into_iter() .map(|plugin| (plugin.manifest.id.clone(), plugin)) .collect(); @@ -292,6 +315,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ init_panel, hide_panel, + get_taskbar_position, + get_arrow_offset, start_probe_batch, list_plugins, get_log_path, @@ -331,8 +356,11 @@ pub fn run() { app_data_dir, app_version: app.package_info().version.to_string(), latest_probe_results: std::collections::HashMap::new(), + last_taskbar_position: None, + last_arrow_offset: None, })); + tray::create(app.handle())?; app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 7009fc4d..b3390350 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -1,7 +1,14 @@ use rquickjs::{Ctx, Exception, Function, Object}; use std::path::PathBuf; -const WHITELISTED_ENV_VARS: [&str; 1] = ["CODEX_HOME"]; +const WHITELISTED_ENV_VARS: [&str; 6] = [ + "CODEX_HOME", + "APPDATA", + "LOCALAPPDATA", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", +]; /// Redact sensitive value to first4...last4 format (UTF-8 safe) fn redact_value(value: &str) -> String { @@ -10,7 +17,14 @@ fn redact_value(value: &str) -> String { "[REDACTED]".to_string() } else { let first4: String = chars.iter().take(4).collect(); - let last4: String = chars.iter().rev().take(4).collect::>().into_iter().rev().collect(); + let last4: String = chars + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); format!("{}...{}", first4, last4) } } @@ -18,10 +32,19 @@ fn redact_value(value: &str) -> String { /// Redact sensitive query parameters in URL fn redact_url(url: &str) -> String { let sensitive_params = [ - "key", "api_key", "apikey", "token", "access_token", "secret", - "password", "auth", "authorization", "bearer", "credential", + "key", + "api_key", + "apikey", + "token", + "access_token", + "secret", + "password", + "auth", + "authorization", + "bearer", + "credential", ]; - + if let Some(query_start) = url.find('?') { let (base, query) = url.split_at(query_start + 1); let redacted_params: Vec = query @@ -31,7 +54,8 @@ fn redact_url(url: &str) -> String { let (name, value) = param.split_at(eq_pos); let value = &value[1..]; // skip '=' let name_lower = name.to_lowercase(); - if sensitive_params.iter().any(|s| name_lower.contains(s)) && !value.is_empty() { + if sensitive_params.iter().any(|s| name_lower.contains(s)) && !value.is_empty() + { format!("{}={}", name, redact_value(value)) } else { param.to_string() @@ -50,49 +74,83 @@ fn redact_url(url: &str) -> String { /// Redact sensitive patterns in response body for logging fn redact_body(body: &str) -> String { let mut result = body.to_string(); - + // Redact JWTs (eyJ... pattern with dots) - let jwt_pattern = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(); - result = jwt_pattern.replace_all(&result, |caps: ®ex_lite::Captures| { - redact_value(&caps[0]) - }).to_string(); - + let jwt_pattern = + regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(); + result = jwt_pattern + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); + // Redact common API key patterns (sk-xxx, pk-xxx, api_xxx, etc.) - let api_key_pattern = regex_lite::Regex::new(r#"["']?(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}["']?"#).unwrap(); - result = api_key_pattern.replace_all(&result, |caps: ®ex_lite::Captures| { - let key = caps[0].trim_matches(|c| c == '"' || c == '\''); - redact_value(key) - }).to_string(); - + let api_key_pattern = + regex_lite::Regex::new(r#"["']?(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}["']?"#) + .unwrap(); + result = api_key_pattern + .replace_all(&result, |caps: ®ex_lite::Captures| { + let key = caps[0].trim_matches(|c| c == '"' || c == '\''); + redact_value(key) + }) + .to_string(); + // Redact JSON values for sensitive keys let sensitive_keys = [ - "name", "password", "token", "access_token", "refresh_token", "secret", - "api_key", "apiKey", "authorization", "bearer", "credential", - "session_token", "sessionToken", "auth_token", "authToken", - "user_id", "account_id", "email", "login", "analytics_tracking_id", + "name", + "password", + "token", + "access_token", + "refresh_token", + "secret", + "api_key", + "apiKey", + "authorization", + "bearer", + "credential", + "session_token", + "sessionToken", + "auth_token", + "authToken", + "user_id", + "account_id", + "email", + "login", + "analytics_tracking_id", ]; for key in sensitive_keys { // Match "key": "value" or "key":"value" let pattern = format!(r#""{}":\s*"([^"]+)""#, key); if let Ok(re) = regex_lite::Regex::new(&pattern) { - result = re.replace_all(&result, |caps: ®ex_lite::Captures| { - let value = &caps[1]; - format!("\"{}\": \"{}\"", key, redact_value(value)) - }).to_string(); + result = re + .replace_all(&result, |caps: ®ex_lite::Captures| { + let value = &caps[1]; + format!("\"{}\": \"{}\"", key, redact_value(value)) + }) + .to_string(); } } - + result } /// Lightweight redaction for plugin log messages (JWT + API key patterns only). fn redact_log_message(msg: &str) -> String { let mut result = msg.to_string(); - if let Ok(jwt_re) = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") { - result = jwt_re.replace_all(&result, |caps: ®ex_lite::Captures| redact_value(&caps[0])).to_string(); + if let Ok(jwt_re) = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") + { + result = jwt_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); } if let Ok(api_re) = regex_lite::Regex::new(r#"(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}"#) { - result = api_re.replace_all(&result, |caps: ®ex_lite::Captures| redact_value(&caps[0])).to_string(); + result = api_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); } result } @@ -141,11 +199,7 @@ pub fn inject_host_api<'js>( Ok(()) } -fn inject_log<'js>( - ctx: &Ctx<'js>, - host: &Object<'js>, - plugin_id: &str, -) -> rquickjs::Result<()> { +fn inject_log<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { let log_obj = Object::new(ctx.clone())?; let pid = plugin_id.to_string(); @@ -193,9 +247,8 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { ctx.clone(), move |ctx_inner: Ctx<'_>, path: String| -> rquickjs::Result { let expanded = expand_path(&path); - std::fs::read_to_string(&expanded).map_err(|e| { - Exception::throw_message(&ctx_inner, &e.to_string()) - }) + std::fs::read_to_string(&expanded) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) }, )?, )?; @@ -206,9 +259,8 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { ctx.clone(), move |ctx_inner: Ctx<'_>, path: String, content: String| -> rquickjs::Result<()> { let expanded = expand_path(&path); - std::fs::write(&expanded, &content).map_err(|e| { - Exception::throw_message(&ctx_inner, &e.to_string()) - }) + std::fs::write(&expanded, &content) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) }, )?, )?; @@ -317,7 +369,8 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rqui let redacted_body = redact_body(&body); let body_preview = if redacted_body.len() > 500 { // UTF-8 safe truncation: find valid char boundary at or before 500 - let truncated: String = redacted_body.char_indices() + let truncated: String = redacted_body + .char_indices() .take_while(|(i, _)| *i < 500) .map(|(_, c)| c) .collect(); @@ -710,11 +763,7 @@ struct LsDiscoverResult { extension_port: Option, } -fn inject_ls<'js>( - ctx: &Ctx<'js>, - host: &Object<'js>, - plugin_id: &str, -) -> rquickjs::Result<()> { +fn inject_ls<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { let ls_obj = Object::new(ctx.clone())?; let pid = plugin_id.to_string(); @@ -724,10 +773,7 @@ fn inject_ls<'js>( ctx.clone(), move |ctx_inner: Ctx<'_>, opts_json: String| -> rquickjs::Result { let opts: LsDiscoverOpts = serde_json::from_str(&opts_json).map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("invalid discover opts: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("invalid discover opts: {}", e)) })?; log::info!( @@ -737,19 +783,33 @@ fn inject_ls<'js>( opts.markers ); - let ps_output = match std::process::Command::new("/bin/ps") - .args(["-ax", "-o", "pid=,command="]) - .output() - { - Ok(o) => o, - Err(e) => { - log::warn!("[plugin:{}] ps failed: {}", pid, e); - return Ok("null".to_string()); + // Platform-specific process listing + let ps_output = if cfg!(target_os = "windows") { + match std::process::Command::new("wmic") + .args(["process", "get", "ProcessId,CommandLine", "/format:list"]) + .output() + { + Ok(o) => o, + Err(e) => { + log::warn!("[plugin:{}] wmic failed: {}", pid, e); + return Ok("null".to_string()); + } + } + } else { + match std::process::Command::new("/bin/ps") + .args(["-ax", "-o", "pid=,command="]) + .output() + { + Ok(o) => o, + Err(e) => { + log::warn!("[plugin:{}] ps failed: {}", pid, e); + return Ok("null".to_string()); + } } }; if !ps_output.status.success() { - log::warn!("[plugin:{}] ps returned non-zero", pid); + log::warn!("[plugin:{}] process listing returned non-zero", pid); return Ok("null".to_string()); } @@ -763,39 +823,80 @@ fn inject_ls<'js>( // non-Codeium provider needs LS discovery, extend patterns here. let mut found: Option<(i32, String)> = None; - for line in ps_stdout.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; + if cfg!(target_os = "windows") { + // Parse wmic output: key=value pairs separated by blank lines + let mut current_pid: Option = None; + let mut current_command: Option = None; + + for line in ps_stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + // End of record - check if it matches + if let (Some(pid), Some(cmd)) = (current_pid, ¤t_command) { + let cmd_lower = cmd.to_lowercase(); + if cmd_lower.contains(&process_name_lower) { + let has_marker = markers_lower.iter().any(|m| { + cmd_lower.contains(&format!("--app_data_dir {}", m)) + || cmd_lower.contains(&format!("\\{}\\", m)) + }); + if has_marker { + found = Some((pid, cmd.clone())); + break; + } + } + } + current_pid = None; + current_command = None; + continue; + } + + if let Some(eq_pos) = trimmed.find('=') { + let key = &trimmed[..eq_pos]; + let value = &trimmed[eq_pos + 1..]; + + if key == "ProcessId" { + current_pid = value.parse::().ok(); + } else if key == "CommandLine" { + current_command = Some(value.to_string()); + } + } } + } else { + // Unix: parse ps output (space-separated pid + command) + for line in ps_stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } - let mut parts = trimmed.splitn(2, char::is_whitespace); - let pid_str = match parts.next() { - Some(s) => s.trim(), - None => continue, - }; - let command = match parts.next() { - Some(s) => s.trim(), - None => continue, - }; + let mut parts = trimmed.splitn(2, char::is_whitespace); + let pid_str = match parts.next() { + Some(s) => s.trim(), + None => continue, + }; + let command = match parts.next() { + Some(s) => s.trim(), + None => continue, + }; - let command_lower = command.to_lowercase(); + let command_lower = command.to_lowercase(); - if !command_lower.contains(&process_name_lower) { - continue; - } + if !command_lower.contains(&process_name_lower) { + continue; + } - let has_marker = markers_lower.iter().any(|m| { - command_lower.contains(&format!("--app_data_dir {}", m)) - || command_lower.contains(&format!("/{}/", m)) - }); - if !has_marker { - continue; - } + let has_marker = markers_lower.iter().any(|m| { + command_lower.contains(&format!("--app_data_dir {}", m)) + || command_lower.contains(&format!("/{}/", m)) + }); + if !has_marker { + continue; + } - if let Ok(p) = pid_str.parse::() { - found = Some((p, command.to_string())); - break; + if let Ok(p) = pid_str.parse::() { + found = Some((p, command.to_string())); + break; + } } } @@ -811,22 +912,15 @@ fn inject_ls<'js>( let csrf = match ls_extract_flag(&command, &opts.csrf_flag) { Some(c) => c, None => { - log::warn!( - "[plugin:{}] CSRF token not found in process args", - pid - ); + log::warn!("[plugin:{}] CSRF token not found in process args", pid); return Ok("null".to_string()); } }; // Extract extension port (optional) - let extension_port = opts - .port_flag - .as_ref() - .and_then(|flag| { - ls_extract_flag(&command, flag) - .and_then(|v| v.parse::().ok()) - }); + let extension_port = opts.port_flag.as_ref().and_then(|flag| { + ls_extract_flag(&command, flag).and_then(|v| v.parse::().ok()) + }); // Extract extra flags (optional) let mut extra = std::collections::HashMap::new(); @@ -840,44 +934,60 @@ fn inject_ls<'js>( } } - // Find lsof binary - let lsof_path = ["/usr/sbin/lsof", "/usr/bin/lsof"] - .iter() - .find(|p| std::path::Path::new(p).exists()) - .copied(); - - let ports = if let Some(lsof) = lsof_path { - match std::process::Command::new(lsof) - .args([ - "-nP", - "-iTCP", - "-sTCP:LISTEN", - "-a", - "-p", - &process_pid.to_string(), - ]) + // Find listening ports + let ports = if cfg!(target_os = "windows") { + // Use netstat on Windows + match std::process::Command::new("netstat") + .args(["-ano", "-p", "TCP"]) .output() { Ok(o) if o.status.success() => { - ls_parse_listening_ports( - &String::from_utf8_lossy(&o.stdout), - ) + ls_parse_netstat_ports(&String::from_utf8_lossy(&o.stdout), process_pid) } Ok(_) => { - log::warn!( - "[plugin:{}] lsof returned non-zero", - pid - ); + log::warn!("[plugin:{}] netstat returned non-zero", pid); Vec::new() } Err(e) => { - log::warn!("[plugin:{}] lsof failed: {}", pid, e); + log::warn!("[plugin:{}] netstat failed: {}", pid, e); Vec::new() } } } else { - log::warn!("[plugin:{}] lsof not found", pid); - Vec::new() + // Find lsof binary on Unix + let lsof_path = ["/usr/sbin/lsof", "/usr/bin/lsof"] + .iter() + .find(|p| std::path::Path::new(p).exists()) + .copied(); + + if let Some(lsof) = lsof_path { + match std::process::Command::new(lsof) + .args([ + "-nP", + "-iTCP", + "-sTCP:LISTEN", + "-a", + "-p", + &process_pid.to_string(), + ]) + .output() + { + Ok(o) if o.status.success() => { + ls_parse_listening_ports(&String::from_utf8_lossy(&o.stdout)) + } + Ok(_) => { + log::warn!("[plugin:{}] lsof returned non-zero", pid); + Vec::new() + } + Err(e) => { + log::warn!("[plugin:{}] lsof failed: {}", pid, e); + Vec::new() + } + } + } else { + log::warn!("[plugin:{}] lsof not found", pid); + Vec::new() + } }; if ports.is_empty() && extension_port.is_none() { @@ -905,10 +1015,7 @@ fn inject_ls<'js>( }; serde_json::to_string(&result).map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("serialize failed: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("serialize failed: {}", e)) }) }, )?, @@ -977,6 +1084,43 @@ fn ls_parse_listening_ports(output: &str) -> Vec { ports.into_iter().collect() } +/// Parse listening port numbers from Windows `netstat -ano` output. +fn ls_parse_netstat_ports(output: &str, target_pid: i32) -> Vec { + let mut ports = std::collections::BTreeSet::new(); + for line in output.lines() { + if !line.contains("LISTENING") { + continue; + } + // netstat output: TCP 127.0.0.1:PORT 0.0.0.0:0 LISTENING PID + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.len() < 5 { + continue; + } + + // Last token is the PID + if let Ok(pid) = tokens[tokens.len() - 1].parse::() { + if pid != target_pid { + continue; + } + } else { + continue; + } + + // Second token should be the local address (127.0.0.1:PORT or 0.0.0.0:PORT) + if let Some(addr_port) = tokens.get(1) { + if let Some(colon_pos) = addr_port.rfind(':') { + let port_str = &addr_port[colon_pos + 1..]; + if let Ok(port) = port_str.parse::() { + if port > 0 && port < 65536 { + ports.insert(port); + } + } + } + } + } + ports.into_iter().collect() +} + fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { let keychain_obj = Object::new(ctx.clone())?; @@ -1065,21 +1209,11 @@ fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result< .output() } else { std::process::Command::new("security") - .args([ - "add-generic-password", - "-s", - &service, - "-w", - &value, - "-U", - ]) + .args(["add-generic-password", "-s", &service, "-w", &value, "-U"]) .output() } .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("keychain write failed: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("keychain write failed: {}", e)) })?; if !output.status.success() { @@ -1128,10 +1262,7 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() .args(["-readonly", "-json", &uri_path, &sql]) .output() .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("sqlite3 exec failed: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) })?; if !output.status.success() { @@ -1163,10 +1294,7 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() .args([&expanded, &sql]) .output() .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("sqlite3 exec failed: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) })?; if !output.status.success() { @@ -1248,23 +1376,38 @@ mod tests { let get: Function = env.get("get").expect("get"); for name in WHITELISTED_ENV_VARS { - let value: Option = get.call((name.to_string(),)).expect("get whitelisted var"); - assert_eq!(value, std::env::var(name).ok(), "{name} should match process env"); + let value: Option = + get.call((name.to_string(),)).expect("get whitelisted var"); + assert_eq!( + value, + std::env::var(name).ok(), + "{name} should match process env" + ); let js_expr = format!(r#"__openusage_ctx.host.env.get("{}")"#, name); let js_value: Option = ctx.eval(js_expr).expect("js get whitelisted var"); - assert_eq!(js_value, std::env::var(name).ok(), "{name} should match process env from JS"); + assert_eq!( + js_value, + std::env::var(name).ok(), + "{name} should match process env from JS" + ); } let blocked: Option = get .call(("__OPENUSAGE_TEST_NOT_WHITELISTED__".to_string(),)) .expect("get blocked var"); - assert!(blocked.is_none(), "non-whitelisted vars must not be exposed"); + assert!( + blocked.is_none(), + "non-whitelisted vars must not be exposed" + ); let js_blocked: Option = ctx .eval(r#"__openusage_ctx.host.env.get("__OPENUSAGE_TEST_NOT_WHITELISTED__")"#) .expect("js get blocked var"); - assert!(js_blocked.is_none(), "non-whitelisted vars must not be exposed from JS"); + assert!( + js_blocked.is_none(), + "non-whitelisted vars must not be exposed from JS" + ); }); } @@ -1293,7 +1436,11 @@ mod tests { let body = r#"{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}"#; let redacted = redact_body(body); // JWT gets redacted to first4...last4 format - assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), "full JWT should be redacted, got: {}", redacted); + assert!( + !redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + "full JWT should be redacted, got: {}", + redacted + ); } #[test] @@ -1307,46 +1454,102 @@ mod tests { fn redact_body_redacts_json_password_field() { let body = r#"{"password": "supersecretpassword123"}"#; let redacted = redact_body(body); - assert!(!redacted.contains("supersecretpassword123"), "password should be redacted, got: {}", redacted); + assert!( + !redacted.contains("supersecretpassword123"), + "password should be redacted, got: {}", + redacted + ); } #[test] fn redact_body_redacts_user_id_and_email() { let body = r#"{"user_id": "user-iupzZ7KFykMLrnzpkHSq7wjo", "email": "rob@sunstory.com"}"#; let redacted = redact_body(body); - assert!(!redacted.contains("user-iupzZ7KFykMLrnzpkHSq7wjo"), "user_id should be redacted, got: {}", redacted); - assert!(!redacted.contains("rob@sunstory.com"), "email should be redacted, got: {}", redacted); + assert!( + !redacted.contains("user-iupzZ7KFykMLrnzpkHSq7wjo"), + "user_id should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("rob@sunstory.com"), + "email should be redacted, got: {}", + redacted + ); // Should show first4...last4 - assert!(redacted.contains("user...7wjo"), "user_id should show first4...last4, got: {}", redacted); - assert!(redacted.contains("rob@....com"), "email should show first4...last4, got: {}", redacted); + assert!( + redacted.contains("user...7wjo"), + "user_id should show first4...last4, got: {}", + redacted + ); + assert!( + redacted.contains("rob@....com"), + "email should show first4...last4, got: {}", + redacted + ); } #[test] fn redact_log_message_redacts_jwt_and_api_key() { let msg = "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U key=sk-1234567890abcdef"; let redacted = redact_log_message(msg); - assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), "JWT should be redacted"); - assert!(!redacted.contains("sk-1234567890abcdef"), "API key should be redacted"); + assert!( + !redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + "JWT should be redacted" + ); + assert!( + !redacted.contains("sk-1234567890abcdef"), + "API key should be redacted" + ); } #[test] fn redact_body_redacts_login_and_analytics_tracking_id() { - let body = r#"{"login":"robinebers","analytics_tracking_id":"c9df3f012bb8c2eb7aae6868ee8da6cf"}"#; + let body = + r#"{"login":"robinebers","analytics_tracking_id":"c9df3f012bb8c2eb7aae6868ee8da6cf"}"#; let redacted = redact_body(body); - assert!(!redacted.contains("robinebers"), "login should be redacted, got: {}", redacted); - assert!(!redacted.contains("c9df3f012bb8c2eb7aae6868ee8da6cf"), "analytics_tracking_id should be redacted, got: {}", redacted); + assert!( + !redacted.contains("robinebers"), + "login should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("c9df3f012bb8c2eb7aae6868ee8da6cf"), + "analytics_tracking_id should be redacted, got: {}", + redacted + ); // login is short (<=12 chars) so becomes [REDACTED]; analytics_tracking_id is long so first4...last4 - assert!(redacted.contains("[REDACTED]"), "login should be redacted, got: {}", redacted); - assert!(redacted.contains("c9df...a6cf"), "analytics_tracking_id should show first4...last4, got: {}", redacted); + assert!( + redacted.contains("[REDACTED]"), + "login should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("c9df...a6cf"), + "analytics_tracking_id should show first4...last4, got: {}", + redacted + ); } #[test] fn redact_body_redacts_name_field() { - let body = r#"{"userStatus":{"name":"Robin Ebers","email":"rob@sunstory.com","planStatus":{}}}"#; + let body = + r#"{"userStatus":{"name":"Robin Ebers","email":"rob@sunstory.com","planStatus":{}}}"#; let redacted = redact_body(body); - assert!(!redacted.contains("Robin Ebers"), "name should be redacted, got: {}", redacted); - assert!(!redacted.contains("rob@sunstory.com"), "email should be redacted, got: {}", redacted); + assert!( + !redacted.contains("Robin Ebers"), + "name should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("rob@sunstory.com"), + "email should be redacted, got: {}", + redacted + ); // "Robin Ebers" is 11 chars (<=12) so becomes [REDACTED] - assert!(redacted.contains("\"name\": \"[REDACTED]\""), "name should show [REDACTED], got: {}", redacted); + assert!( + redacted.contains("\"name\": \"[REDACTED]\""), + "name should show [REDACTED], got: {}", + redacted + ); } } diff --git a/src-tauri/src/window_manager.rs b/src-tauri/src/window_manager.rs index 842d5439..0b92d8c8 100644 --- a/src-tauri/src/window_manager.rs +++ b/src-tauri/src/window_manager.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, Manager, PhysicalPosition}; +use tauri::{AppHandle, Emitter, Manager, PhysicalPosition}; #[cfg(target_os = "macos")] use tauri::{Position, Size}; @@ -92,6 +92,16 @@ pub fn setup_windows_window(app_handle: &AppHandle) -> tauri::Result<()> { Ok(()) } +/// Taskbar position enum - available on all platforms for AppState compatibility +#[derive(Debug, Clone, Copy, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TaskbarPosition { + Top, + Bottom, + Left, + Right, +} + /// Position the window at the tray icon location #[cfg(target_os = "windows")] pub fn position_window_at_tray( @@ -131,34 +141,174 @@ pub fn position_window_at_tray( .map(|m| m.scale_factor()) .unwrap_or(1.0); - // Calculate position: center horizontally above the tray icon + let monitor = target_monitor.as_ref(); + let monitor_rect = monitor.map(|m| { + let pos = m.position(); + let size = m.size(); + (pos.x, pos.y, size.width as i32, size.height as i32) + }); + let work_rect = monitor.map(|m| { + let area = m.work_area(); + let pos = area.position; + let size = area.size; + (pos.x, pos.y, size.width as i32, size.height as i32) + }); + + // Detect taskbar position based on icon location + let taskbar_position = detect_taskbar_position(icon_position, icon_size, monitor_rect); + + // Calculate window position based on taskbar location + let (window_x, window_y) = calculate_window_position( + icon_position, + icon_size, + window_width, + window_height, + taskbar_position, + monitor_rect, + work_rect, + ); + + // Convert to logical position + let logical_pos = LogicalPosition::new( + window_x as f64 / scale_factor, + window_y as f64 / scale_factor, + ); + + window.set_position(tauri::Position::Logical(logical_pos))?; + + // Calculate arrow offset: where the icon center is relative to window edge + // Top/Bottom -> X offset, Left/Right -> Y offset let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); - let window_x = icon_center_x - (window_width / 2); - let window_y = icon_position.y - window_height; - - // Clamp to monitor bounds - let final_x = if let Some(ref monitor) = target_monitor { - let monitor_x = monitor.position().x; - let monitor_width = monitor.size().width as i32; - window_x.clamp(monitor_x, monitor_x + monitor_width - window_width) - } else { - window_x.max(0) + let icon_center_y = icon_position.y + (icon_size.height as i32 / 2); + let arrow_offset_physical = match taskbar_position { + TaskbarPosition::Left | TaskbarPosition::Right => icon_center_y - window_y, + TaskbarPosition::Top | TaskbarPosition::Bottom => icon_center_x - window_x, + }; + let arrow_offset_logical = (arrow_offset_physical as f64 / scale_factor) as i32; + + // Store taskbar position + arrow offset for frontend fallback + if let Some(state) = app_handle.try_state::>() { + if let Ok(mut app_state) = state.lock() { + app_state.last_taskbar_position = Some(taskbar_position); + app_state.last_arrow_offset = Some(arrow_offset_logical); + } + } + + println!( + "DEBUG: Positioning window. Arrow Offset: {}, Taskbar: {:?}", + arrow_offset_logical, taskbar_position + ); + + // Emit event to frontend with arrow position info + if let Err(e) = window.emit( + "window:positioned", + serde_json::json!({ + "arrowOffset": arrow_offset_logical, + "taskbarPosition": taskbar_position, + }), + ) { + println!("ERROR: Failed to emit window:positioned event: {}", e); + } + + Ok(()) +} + +/// Detect taskbar position based on tray icon location +#[cfg(target_os = "windows")] +fn detect_taskbar_position( + icon_position: PhysicalPosition, + icon_size: tauri::PhysicalSize, + monitor_rect: Option<(i32, i32, i32, i32)>, +) -> TaskbarPosition { + let Some((monitor_x, monitor_y, monitor_width, monitor_height)) = monitor_rect else { + return TaskbarPosition::Bottom; // Default to bottom }; - let final_y = if let Some(ref monitor) = target_monitor { - let monitor_y = monitor.position().y; - monitor_y.max(window_y) + let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); + let icon_center_y = icon_position.y + (icon_size.height as i32 / 2); + + // Calculate distance to each edge + let dist_to_left = icon_center_x - monitor_x; + let dist_to_right = (monitor_x + monitor_width) - icon_center_x; + let dist_to_top = icon_center_y - monitor_y; + let dist_to_bottom = (monitor_y + monitor_height) - icon_center_y; + + // Find the closest edge + let min_dist = dist_to_left + .min(dist_to_right) + .min(dist_to_top) + .min(dist_to_bottom); + + if min_dist == dist_to_top { + TaskbarPosition::Top + } else if min_dist == dist_to_bottom { + TaskbarPosition::Bottom + } else if min_dist == dist_to_left { + TaskbarPosition::Left } else { - window_y.max(0) + TaskbarPosition::Right + } +} + +/// Calculate window position based on taskbar position +#[cfg(target_os = "windows")] +fn calculate_window_position( + icon_position: PhysicalPosition, + icon_size: tauri::PhysicalSize, + window_width: i32, + window_height: i32, + taskbar_position: TaskbarPosition, + monitor_rect: Option<(i32, i32, i32, i32)>, + work_rect: Option<(i32, i32, i32, i32)>, +) -> (i32, i32) { + let Some((monitor_x, monitor_y, monitor_width, monitor_height)) = monitor_rect else { + // Fallback: center above icon + let x = icon_position.x + (icon_size.width as i32 / 2) - (window_width / 2); + let y = icon_position.y - window_height; + return (x.max(0), y.max(0)); }; - // Convert to logical position - let logical_pos = - LogicalPosition::new(final_x as f64 / scale_factor, final_y as f64 / scale_factor); + let (bounds_x, bounds_y, bounds_width, bounds_height) = + work_rect.unwrap_or((monitor_x, monitor_y, monitor_width, monitor_height)); - window.set_position(tauri::Position::Logical(logical_pos))?; + let padding = 8; // Gap between window and taskbar - Ok(()) + let (x, y) = match taskbar_position { + TaskbarPosition::Bottom => { + // Window appears above the taskbar + let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); + let window_x = icon_center_x - (window_width / 2); + let window_y = icon_position.y - window_height - padding; + (window_x, window_y) + } + TaskbarPosition::Top => { + // Window appears below the taskbar + let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); + let window_x = icon_center_x - (window_width / 2); + let window_y = icon_position.y + icon_size.height as i32 + padding; + (window_x, window_y) + } + TaskbarPosition::Left => { + // Window appears to the right of the taskbar + let window_x = bounds_x + padding; + let icon_center_y = icon_position.y + (icon_size.height as i32 / 2); + let window_y = icon_center_y - (window_height / 2); + (window_x, window_y) + } + TaskbarPosition::Right => { + // Window appears to the left of the taskbar + let window_x = bounds_x + bounds_width - window_width - padding; + let icon_center_y = icon_position.y + (icon_size.height as i32 / 2); + let window_y = icon_center_y - (window_height / 2); + (window_x, window_y) + } + }; + + // Clamp to work area bounds + let final_x = x.clamp(bounds_x, bounds_x + bounds_width - window_width); + let final_y = y.clamp(bounds_y, bounds_y + bounds_height - window_height); + + (final_x, final_y) } /// macOS version delegates to existing panel implementation diff --git a/src/App.tsx b/src/App.tsx index d050962f..3d226373 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { invoke, isTauri } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" -import { getCurrentWindow, PhysicalSize, currentMonitor } from "@tauri-apps/api/window" +import { getCurrentWindow, PhysicalSize, PhysicalPosition, currentMonitor } from "@tauri-apps/api/window" import { getVersion } from "@tauri-apps/api/app" import { TrayIcon } from "@tauri-apps/api/tray" import { platform } from "@tauri-apps/plugin-os" @@ -48,7 +48,7 @@ import { const PANEL_WIDTH = 400; const MAX_HEIGHT_FALLBACK_PX = 600; const MAX_HEIGHT_FRACTION_OF_MONITOR = 0.8; -const ARROW_OVERHEAD_PX = 37; // .tray-arrow (7px) + wrapper pt-1.5 (6px) + bottom p-6 (24px) +const ARROW_OVERHEAD_PX = 32; // Arrow (~16px) + container padding (16px) const TRAY_SETTINGS_DEBOUNCE_MS = 2000; const TRAY_PROBE_DEBOUNCE_MS = 500; @@ -80,6 +80,13 @@ function App() { const [maxPanelHeightPx, setMaxPanelHeightPx] = useState(null) const maxPanelHeightPxRef = useRef(null) const [appVersion, setAppVersion] = useState("...") + // Track taskbar position for anchor-aware resizing (Windows) + type TaskbarPosition = "top" | "bottom" | "left" | "right" | null + const [taskbarPosition, setTaskbarPosition] = useState(null) + // Track last window height to calculate delta for repositioning + const lastWindowHeightRef = useRef(null) + // Arrow offset from left edge (in logical px) - where tray icon center is relative to window + const [arrowOffset, setArrowOffset] = useState(null) const { updateStatus, triggerInstall } = useAppUpdate() const [showAbout, setShowAbout] = useState(false) @@ -211,7 +218,16 @@ function App() { const unlisteners: (() => void)[] = [] async function setup() { - const u1 = await listen("tray:navigate", (event) => { + const currentWindow = getCurrentWindow() + + const u1 = await listen("tray:navigate", async (event) => { + // Capture current height before navigation so we can calculate delta + try { + const size = await currentWindow.outerSize() + lastWindowHeightRef.current = size.height + } catch { + lastWindowHeightRef.current = null + } setActiveView(event.payload as ActiveView) }) if (cancelled) { u1(); return } @@ -222,6 +238,41 @@ function App() { }) if (cancelled) { u2(); return } unlisteners.push(u2) + + // Listen for window focus events to capture current height as baseline + // This ensures we can calculate proper deltas for anchor-aware resizing + const u3 = await currentWindow.onFocusChanged(async ({ payload: focused }) => { + if (focused) { + // Window just gained focus - capture current height as baseline for delta calculation + try { + const size = await currentWindow.outerSize() + lastWindowHeightRef.current = size.height + console.log('[FOCUS] Window focused, captured baseline height:', size.height) + + // Fetch latest positioning info (in case event was missed) + const pos = await invoke('get_taskbar_position'); + if (pos) setTaskbarPosition(pos as TaskbarPosition); + + const offset = await invoke('get_arrow_offset'); + if (offset !== null) setArrowOffset(offset); + } catch { + // Fallback: null will cause first resize to just set size without repositioning + lastWindowHeightRef.current = null + console.log('[FOCUS] Window focused, failed to capture height') + } + } + }) + if (cancelled) { u3(); return } + unlisteners.push(u3) + + // Listen for window positioning events to align arrow with tray icon + const u4 = await listen<{ arrowOffset: number; taskbarPosition: string }>("window:positioned", (event) => { + setArrowOffset(event.payload.arrowOffset) + setTaskbarPosition(event.payload.taskbarPosition as TaskbarPosition) + console.log('[POSITIONED] Arrow offset:', event.payload.arrowOffset, 'Taskbar:', event.payload.taskbarPosition) + }) + if (cancelled) { u4(); return } + unlisteners.push(u4) } void setup() @@ -232,60 +283,105 @@ function App() { }, []) // Auto-resize window to fit content using ResizeObserver + // CRITICAL: Anchor-aware resizing - keep the edge closest to taskbar fixed useEffect(() => { const container = containerRef.current; if (!container) return; - const resizeWindow = async () => { - const factor = window.devicePixelRatio; + let debounceTimer: ReturnType | null = null; + let isResizing = false; - const width = Math.ceil(PANEL_WIDTH * factor); - const desiredHeightLogical = Math.max(1, container.scrollHeight); + const resizeWindow = async () => { + // Prevent concurrent resize operations + if (isResizing) return; + isResizing = true; - let maxHeightPhysical: number | null = null; - let maxHeightLogical: number | null = null; try { - const monitor = await currentMonitor(); - if (monitor) { - maxHeightPhysical = Math.floor(monitor.size.height * MAX_HEIGHT_FRACTION_OF_MONITOR); - maxHeightLogical = Math.floor(maxHeightPhysical / factor); + const factor = window.devicePixelRatio; + const width = Math.ceil(PANEL_WIDTH * factor); + const desiredHeightLogical = Math.max(1, container.scrollHeight); + + let maxHeightPhysical: number | null = null; + let maxHeightLogical: number | null = null; + try { + const monitor = await currentMonitor(); + if (monitor) { + maxHeightPhysical = Math.floor(monitor.size.height * MAX_HEIGHT_FRACTION_OF_MONITOR); + maxHeightLogical = Math.floor(maxHeightPhysical / factor); + } + } catch { + // fall through to fallback } - } catch { - // fall through to fallback - } - if (maxHeightLogical === null) { - const screenAvailHeight = Number(window.screen?.availHeight) || MAX_HEIGHT_FALLBACK_PX; - maxHeightLogical = Math.floor(screenAvailHeight * MAX_HEIGHT_FRACTION_OF_MONITOR); - maxHeightPhysical = Math.floor(maxHeightLogical * factor); - } + if (maxHeightLogical === null) { + const screenAvailHeight = Number(window.screen?.availHeight) || MAX_HEIGHT_FALLBACK_PX; + maxHeightLogical = Math.floor(screenAvailHeight * MAX_HEIGHT_FRACTION_OF_MONITOR); + maxHeightPhysical = Math.floor(maxHeightLogical * factor); + } - if (maxPanelHeightPxRef.current !== maxHeightLogical) { - maxPanelHeightPxRef.current = maxHeightLogical; - setMaxPanelHeightPx(maxHeightLogical); - } + if (maxPanelHeightPxRef.current !== maxHeightLogical) { + maxPanelHeightPxRef.current = maxHeightLogical; + setMaxPanelHeightPx(maxHeightLogical); + } - const desiredHeightPhysical = Math.ceil(desiredHeightLogical * factor); - const height = Math.ceil(Math.min(desiredHeightPhysical, maxHeightPhysical!)); + const desiredHeightPhysical = Math.ceil(desiredHeightLogical * factor); + const newHeight = Math.ceil(Math.min(desiredHeightPhysical, maxHeightPhysical!)); + const previousHeight = lastWindowHeightRef.current; - try { const currentWindow = getCurrentWindow(); - await currentWindow.setSize(new PhysicalSize(width, height)); + + // Fetch current taskbar position from backend (Windows stores this on tray click) + let currentTaskbarPos: TaskbarPosition = null; + try { + currentTaskbarPos = await invoke("get_taskbar_position"); + setTaskbarPosition(currentTaskbarPos); + } catch { + // Fallback: not available or macOS + } + + // On Windows with bottom/right taskbar, we need to reposition when height changes + // to keep the bottom/right edge anchored + if (previousHeight !== null && previousHeight !== newHeight && currentTaskbarPos) { + const heightDelta = newHeight - previousHeight; + + // Get current window position + const pos = await currentWindow.outerPosition(); + + if (currentTaskbarPos === "bottom") { + // Bottom taskbar: keep bottom edge fixed → move window UP when growing + const newY = pos.y - heightDelta; + await currentWindow.setPosition(new PhysicalPosition(pos.x, newY)); + } + // Top/Left taskbar: default behavior (top-left anchored) + // Right taskbar: no vertical adjustment needed for height changes + } + + await currentWindow.setSize(new PhysicalSize(width, newHeight)); + lastWindowHeightRef.current = newHeight; } catch (e) { console.error("Failed to resize window:", e); + } finally { + isResizing = false; } }; - // Initial resize + // Debounced resize to prevent rapid consecutive calls + const debouncedResize = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(resizeWindow, 16); // ~1 frame at 60fps + }; + + // Initial resize (no debounce) resizeWindow(); - // Observe size changes - const observer = new ResizeObserver(() => { - resizeWindow(); - }); + // Observe size changes with debouncing + const observer = new ResizeObserver(debouncedResize); observer.observe(container); - return () => observer.disconnect(); + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + observer.disconnect(); + }; }, [activeView, displayPlugins]); const getErrorMessage = useCallback((output: PluginOutput) => { @@ -725,10 +821,42 @@ function App() { ) } + const isSideTaskbar = taskbarPosition === "left" || taskbarPosition === "right" + const isTopTaskbar = taskbarPosition === "top" + const isLeftTaskbar = taskbarPosition === "left" + const isRightTaskbar = taskbarPosition === "right" + + // Padding for shadow: needs ~16px to not clip the box-shadow + // Arrow side gets 8px (arrow is 16px), opposite side gets 16px for shadow + const containerClasses = isWindows + ? isSideTaskbar + ? "flex flex-row items-center w-full py-4 px-2 bg-transparent" + : isTopTaskbar + ? "flex flex-col items-center justify-start w-full px-4 pt-2 pb-4 bg-transparent" + : "flex flex-col items-center justify-end w-full px-4 pt-4 pb-2 bg-transparent" + : "flex flex-col items-center justify-start w-full px-4 pt-2 pb-4 bg-transparent"; + + // Dynamic arrow positioning to align with tray icon + const ARROW_HALF_SIZE_PX = 7; + const PANEL_HORIZONTAL_PADDING_PX = 16; // px-4 + const PANEL_VERTICAL_PADDING_PX = 16; // py-4 + const arrowStyle = arrowOffset !== null + ? isSideTaskbar + ? ({ + alignSelf: "flex-start", + marginTop: `${arrowOffset - ARROW_HALF_SIZE_PX - PANEL_VERTICAL_PADDING_PX}px`, + } as const) + : ({ + alignSelf: "flex-start", + marginLeft: `${arrowOffset - ARROW_HALF_SIZE_PX - PANEL_HORIZONTAL_PADDING_PX}px`, + } as const) + : undefined; + return ( -
- {/* macOS: Arrow at top pointing up | Windows: Arrow at bottom pointing down */} - {!isWindows &&
} +
+ {/* macOS: top arrow; Windows: top/bottom/side based on taskbar */} + {(!isWindows || isTopTaskbar) &&
} + {isWindows && isLeftTaskbar &&
}
+ {isWindows && isRightTaskbar &&
} {/* Windows: Arrow at bottom pointing down toward taskbar */} - {isWindows &&
} + {isWindows && !isSideTaskbar && !isTopTaskbar &&
}
); } diff --git a/src/index.css b/src/index.css index d5968ebd..0684a9f1 100644 --- a/src/index.css +++ b/src/index.css @@ -212,6 +212,50 @@ webview { border-top: 6px solid var(--card); } +/* Arrow pointing left toward the tray icon (Windows - left taskbar) */ +.tray-arrow-left { + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 7px solid var(--border); + position: relative; + flex-shrink: 0; +} +.tray-arrow-left::after { + content: ""; + position: absolute; + top: -6px; + left: 1.5px; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 6px solid var(--card); +} + +/* Arrow pointing right toward the tray icon (Windows - right taskbar) */ +.tray-arrow-right { + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-left: 7px solid var(--border); + position: relative; + flex-shrink: 0; +} +.tray-arrow-right::after { + content: ""; + position: absolute; + top: -6px; + right: 1.5px; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 6px solid var(--card); +} + /* Border beam for the update button */ .update-border-beam { position: relative; From f3de4ab92c7d6a74f29722398554030321eee70f Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Mon, 9 Feb 2026 14:04:05 -0500 Subject: [PATCH 03/16] fix: harden Windows support plumbing Improve Windows CI/release coverage and document updater signing limits. --- .github/workflows/ci.yml | 21 ++++++++- .github/workflows/publish.yml | 15 +++++- bun.lock | 3 ++ docs/breadcrumbs.md | 2 + docs/choices.md | 12 +++++ docs/providers/antigravity.md | 11 ++++- docs/specs/2026-02-09-windows-updater.md | 16 +++++++ docs/specs/tauri-updater-publish.md | 4 ++ src-tauri/Cargo.lock | 43 +++++++++++++++--- src-tauri/capabilities/default.json | 4 +- src-tauri/src/plugin_engine/manifest.rs | 11 +++++ src-tauri/src/plugin_engine/mod.rs | 58 ++++++++++++++++++++---- src-tauri/src/plugin_engine/runtime.rs | 57 ++++++++++++++++------- 13 files changed, 218 insertions(+), 39 deletions(-) create mode 100644 docs/specs/2026-02-09-windows-updater.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50cc26a6..2c0efa69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,11 @@ permissions: jobs: check: name: Lint, Type-check, Build, Test - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -18,6 +22,15 @@ jobs: with: bun-version: latest + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Install dependencies (Ubuntu only) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - run: bun install - name: Type-check and build @@ -25,3 +38,9 @@ jobs: - name: Run tests run: bun run test + + - name: Build Tauri (Windows only) + if: matrix.os == 'windows-latest' + run: bun run tauri build + env: + CI: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 500e84b8..d252742a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,6 +24,8 @@ jobs: args: "--target aarch64-apple-darwin" - platform: macos-latest args: "--target x86_64-apple-darwin" + - platform: windows-latest + args: "--target x86_64-pc-windows-msvc" runs-on: ${{ matrix.platform }} env: RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.tag }} @@ -34,7 +36,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin,x86_64-apple-darwin + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || 'x86_64-pc-windows-msvc' }} - uses: swatinem/rust-cache@v2 with: workspaces: "./src-tauri -> target" @@ -46,14 +48,20 @@ jobs: - name: Bundle plugins run: bun run bundle:plugins - name: Verify bundled plugins + shell: bash run: | - COUNT=$(find src-tauri/resources/bundled_plugins -maxdepth 2 -name plugin.json | wc -l | tr -d ' ') + if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + COUNT=$(find src-tauri/resources/bundled_plugins -maxdepth 2 -name plugin.json | wc -l | tr -d ' \r') + else + COUNT=$(find src-tauri/resources/bundled_plugins -maxdepth 2 -name plugin.json | wc -l | tr -d ' ') + fi if [[ "$COUNT" -lt 1 ]]; then echo "No bundled plugins found under src-tauri/resources/bundled_plugins." exit 1 fi - name: Validate release tag + shell: bash run: | if [[ -z "$RELEASE_TAG" ]]; then echo "Missing RELEASE_TAG (push a v* tag, or provide workflow_dispatch input 'tag')." @@ -65,6 +73,7 @@ jobs: fi - name: Validate app version matches tag + shell: bash run: | TAG_VERSION="${RELEASE_TAG#v}" @@ -86,6 +95,7 @@ jobs: fi - name: Import Apple Developer Certificate + if: matrix.platform == 'macos-latest' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -123,6 +133,7 @@ jobs: args: ${{ matrix.args }} - name: Verify updater assets uploaded + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/bun.lock b/bun.lock index 44e69650..fb07d502 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", "@tauri-apps/plugin-updater": "^2.10.0", @@ -309,6 +310,8 @@ "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], + "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="], diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 228ac218..1e79c46b 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -9,3 +9,5 @@ - Windows plugin scan: identified OS path/keychain blockers per plugin. - Windows plugins: add Windows path candidates for Codex/Claude/Cursor/Windsurf and guard keychain usage to macOS. - Windows OS gating: enable windows in plugin manifests and add actionable missing-path errors for testers. +- Cleanup: moved `WINDOWS_CHANGES.md` and reserved `nul` file to trash; kept `src/contexts/taskbar-context.tsx` for later wiring. +- Windows updater: documented that production updates require Authenticode signing; marked current state as test-only. diff --git a/docs/choices.md b/docs/choices.md index 06d5e2bc..48b47a89 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -73,3 +73,15 @@ This document records opinionated defaults chosen during development. **Context:** Testers need actionable path hints when auth/state files cannot be found. **Decision:** Provide Windows-specific error strings that mention likely file locations (AppData/UserProfile) and ask testers to report actual paths. + +### Keep Taskbar Context File For Follow-Up + +**Context:** `src/contexts/taskbar-context.tsx` exists but is not yet wired into the app. + +**Decision:** Keep the file in place (untracked for now) to integrate once taskbar state is finalized; avoid deleting to prevent churn while Windows support stabilizes. + +### Windows Auto-Update Requires Signing + +**Context:** Updater flow builds for Windows but CI has no Windows code signing. + +**Decision:** Document Windows auto-update as test-only until Authenticode signing is configured. diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index 62377d88..7189713e 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -21,13 +21,20 @@ The language server listens on a random localhost port. Three values must be dis ```bash # 1. Find process and extract CSRF token +# macOS/Linux: ps -ax -o pid=,command= | grep 'language_server_macos.*antigravity' +# Windows: +wmic process where "name='language_server_windows_x64.exe'" get ProcessId,CommandLine + # Match: --app_data_dir antigravity OR path contains /antigravity/ # Extract: --csrf_token # Extract: --extension_server_port (HTTP fallback) # 2. Find listening ports +# macOS/Linux: lsof -nP -iTCP -sTCP:LISTEN -a -p +# Windows: +netstat -ano | findstr # 3. Probe each port to find the Connect-RPC endpoint POST https://127.0.0.1:/.../GetUnleashData → first 200 OK wins @@ -158,8 +165,10 @@ Interestingly, non-Google models (Claude, GPT-OSS) are proxied through Codeium/W ## Plugin Strategy -1. Discover LS process via `ctx.host.ls.discover()` (ps + lsof) +1. Discover LS process via `ctx.host.ls.discover()` (ps/wmic + lsof/netstat) 2. Probe ports with `GetUnleashData` to find the Connect-RPC endpoint 3. Call `GetUserStatus` for plan name + per-model quota 4. Fall back to `GetCommandModelConfigs` if `GetUserStatus` fails 5. If LS not running: error "Start Antigravity and try again." + +**Platform support:** macOS, Linux, Windows diff --git a/docs/specs/2026-02-09-windows-updater.md b/docs/specs/2026-02-09-windows-updater.md new file mode 100644 index 00000000..df3afcca --- /dev/null +++ b/docs/specs/2026-02-09-windows-updater.md @@ -0,0 +1,16 @@ +2026-02-09 + +# Windows updater status + +## Goal +- Make Windows auto-update production-ready once signing is configured. + +## Current state +- Updater plugin is enabled and publishes `latest.json` for Windows builds. +- Windows signing is not configured in CI. + +## Decision +- Treat Windows auto-update as test-only until Authenticode signing is added. + +## Follow-up +- Add Windows code signing to publish workflow before enabling production updates. diff --git a/docs/specs/tauri-updater-publish.md b/docs/specs/tauri-updater-publish.md index 899c0377..bd11e461 100644 --- a/docs/specs/tauri-updater-publish.md +++ b/docs/specs/tauri-updater-publish.md @@ -23,3 +23,7 @@ ## Runtime definition-of-done - `src-tauri/tauri.conf.json` updater `endpoints` points to `.../releases/latest/download/latest.json`. - Release `latest.json` exists and references the same release’s assets. + +## Windows notes +- Windows auto-update requires Authenticode-signed installers. +- Until signing is configured, treat Windows updater as test-only (downloads may be blocked by SmartScreen/UAC). diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 72480c83..02886340 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -915,7 +915,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1100,7 +1100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1490,6 +1490,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2911,6 +2921,7 @@ dependencies = [ "tauri-plugin-aptabase", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-os", "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", @@ -3452,7 +3463,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3910,7 +3921,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3968,7 +3979,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4857,6 +4868,24 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-os" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" @@ -5027,7 +5056,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5822,7 +5851,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e029a742..a85e482e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,7 +7,9 @@ "core:default", "core:tray:default", "core:image:default", - "core:window:allow-set-size", +"core:window:allow-set-size", + "core:window:allow-set-position", + "core:window:allow-outer-position", "core:window:allow-outer-size", "core:window:allow-inner-size", "core:window:allow-scale-factor", diff --git a/src-tauri/src/plugin_engine/manifest.rs b/src-tauri/src/plugin_engine/manifest.rs index 4a0ed849..2822b6da 100644 --- a/src-tauri/src/plugin_engine/manifest.rs +++ b/src-tauri/src/plugin_engine/manifest.rs @@ -14,6 +14,15 @@ pub struct ManifestLine { pub primary_order: Option, } +/// Supported operating systems for a plugin +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum SupportedOs { + Macos, + Windows, + Linux, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginManifest { @@ -25,6 +34,8 @@ pub struct PluginManifest { pub icon: String, pub brand_color: Option, pub lines: Vec, + /// List of supported operating systems. If not specified, all platforms are supported. + pub os: Option>, } #[derive(Debug, Clone)] diff --git a/src-tauri/src/plugin_engine/mod.rs b/src-tauri/src/plugin_engine/mod.rs index f557e925..6ab88287 100644 --- a/src-tauri/src/plugin_engine/mod.rs +++ b/src-tauri/src/plugin_engine/mod.rs @@ -2,23 +2,41 @@ pub mod host_api; pub mod manifest; pub mod runtime; -use manifest::LoadedPlugin; +use manifest::{LoadedPlugin, SupportedOs}; use std::path::{Path, PathBuf}; +/// Get the current OS as a SupportedOs enum +fn current_os() -> SupportedOs { + #[cfg(target_os = "macos")] + return SupportedOs::Macos; + #[cfg(target_os = "windows")] + return SupportedOs::Windows; + #[cfg(target_os = "linux")] + return SupportedOs::Linux; + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + compile_error!("Unsupported target OS"); +} + pub fn initialize_plugins( app_data_dir: &Path, resource_dir: &Path, ) -> (PathBuf, Vec) { + let current = current_os(); + if let Some(dev_dir) = find_dev_plugins_dir() { if !is_dir_empty(&dev_dir) { - let plugins = manifest::load_plugins_from_dir(&dev_dir); + let plugins = filter_plugins_by_os(manifest::load_plugins_from_dir(&dev_dir), current); return (dev_dir, plugins); } } let install_dir = app_data_dir.join("plugins"); if let Err(err) = std::fs::create_dir_all(&install_dir) { - log::warn!("failed to create install dir {}: {}", install_dir.display(), err); + log::warn!( + "failed to create install dir {}: {}", + install_dir.display(), + err + ); } let bundled_dir = resolve_bundled_dir(resource_dir); @@ -26,10 +44,30 @@ pub fn initialize_plugins( copy_dir_recursive(&bundled_dir, &install_dir); } - let plugins = manifest::load_plugins_from_dir(&install_dir); + let plugins = filter_plugins_by_os(manifest::load_plugins_from_dir(&install_dir), current); (install_dir, plugins) } +/// Filter plugins based on OS support. If no OS is specified, plugin is loaded on all platforms. +fn filter_plugins_by_os(plugins: Vec, current_os: SupportedOs) -> Vec { + plugins + .into_iter() + .filter(|p| { + let should_load = match &p.manifest.os { + Some(supported_os_list) => supported_os_list.contains(¤t_os), + None => true, // No OS specified = all platforms + }; + if !should_load { + log::info!( + "skipping plugin '{}' - not supported on this OS", + p.manifest.id + ); + } + should_load + }) + .collect() +} + fn find_dev_plugins_dir() -> Option { let cwd = std::env::current_dir().ok()?; let direct = cwd.join("plugins"); @@ -78,7 +116,11 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { let file_type = match entry.file_type() { Ok(file_type) => file_type, Err(err) => { - log::warn!("failed to read file type for {}: {}", src_path.display(), err); + log::warn!( + "failed to read file type for {}: {}", + src_path.display(), + err + ); continue; } }; @@ -87,11 +129,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { } if file_type.is_dir() { if let Err(err) = std::fs::create_dir_all(&dst_path) { - log::warn!( - "failed to create dir {}: {}", - dst_path.display(), - err - ); + log::warn!("failed to create dir {}: {}", dst_path.display(), err); continue; } copy_dir_recursive(&src_path, &dst_path); diff --git a/src-tauri/src/plugin_engine/runtime.rs b/src-tauri/src/plugin_engine/runtime.rs index 45e5e7e0..1acb58a2 100644 --- a/src-tauri/src/plugin_engine/runtime.rs +++ b/src-tauri/src/plugin_engine/runtime.rs @@ -50,11 +50,7 @@ pub struct PluginOutput { pub icon_url: String, } -pub fn run_probe( - plugin: &LoadedPlugin, - app_data_dir: &PathBuf, - app_version: &str, -) -> PluginOutput { +pub fn run_probe(plugin: &LoadedPlugin, app_data_dir: &PathBuf, app_version: &str) -> PluginOutput { let fallback = error_output(plugin, "runtime error".to_string()); let rt = match Runtime::new() { @@ -113,7 +109,9 @@ pub fn run_probe( let result: Object = if result_value.is_promise() { let promise: Promise = match result_value.into_promise() { Some(promise) => promise, - None => return error_output(plugin, "probe() returned invalid promise".to_string()), + None => { + return error_output(plugin, "probe() returned invalid promise".to_string()) + } }; match promise.finish::() { Ok(obj) => obj, @@ -129,7 +127,10 @@ pub fn run_probe( } }; - let plan: Option = result.get::<_, String>("plan").ok().filter(|s| !s.is_empty()); + let plan: Option = result + .get::<_, String>("plan") + .ok() + .filter(|s| !s.is_empty()); let lines = match parse_lines(&result) { Ok(lines) if !lines.is_empty() => lines, @@ -167,13 +168,21 @@ fn parse_lines(result: &Object) -> Result, String> { match line_type.as_str() { "text" => { let value = line.get::<_, String>("value").unwrap_or_default(); - out.push(MetricLine::Text { label, value, color, subtitle }); + out.push(MetricLine::Text { + label, + value, + color, + subtitle, + }); } "progress" => { let used_value: Value = match line.get("used") { Ok(v) => v, Err(_) => { - out.push(error_line(format!("progress line at index {} missing used", idx))); + out.push(error_line(format!( + "progress line at index {} missing used", + idx + ))); continue; } }; @@ -324,9 +333,8 @@ fn parse_lines(result: &Object) -> Result, String> { Some(value) } else { // ISO-like but missing timezone: assume UTC. - let is_missing_tz = value.contains('T') - && !value.ends_with('Z') - && { + let is_missing_tz = + value.contains('T') && !value.ends_with('Z') && { let tail = value.splitn(2, 'T').nth(1).unwrap_or(""); !tail.contains('+') && !tail.contains('-') }; @@ -365,7 +373,8 @@ fn parse_lines(result: &Object) -> Result, String> { }; // Parse optional periodDurationMs - let period_duration_ms: Option = match line.get::<_, Value>("periodDurationMs") { + let period_duration_ms: Option = match line.get::<_, Value>("periodDurationMs") + { Ok(val) => { if val.is_null() || val.is_undefined() { None @@ -374,11 +383,17 @@ fn parse_lines(result: &Object) -> Result, String> { if ms > 0 { Some(ms) } else { - log::warn!("periodDurationMs at index {} must be positive, omitting", idx); + log::warn!( + "periodDurationMs at index {} must be positive, omitting", + idx + ); None } } else { - log::warn!("invalid periodDurationMs at index {} (non-number), omitting", idx); + log::warn!( + "invalid periodDurationMs at index {} (non-number), omitting", + idx + ); None } } @@ -397,7 +412,12 @@ fn parse_lines(result: &Object) -> Result, String> { } "badge" => { let text = line.get::<_, String>("text").unwrap_or_default(); - out.push(MetricLine::Badge { label, text, color, subtitle }); + out.push(MetricLine::Badge { + label, + text, + color, + subtitle, + }); } _ => { out.push(error_line(format!( @@ -531,6 +551,9 @@ mod tests { let json: JsonValue = serde_json::to_value(&line).expect("serialize"); let obj = json.as_object().expect("object"); assert!(obj.get("resetsAt").is_some(), "expected resetsAt key"); - assert!(obj.get("resets_at").is_none(), "did not expect resets_at key"); + assert!( + obj.get("resets_at").is_none(), + "did not expect resets_at key" + ); } } From dc88be42759ffb36599dac5c3a7a137e25297c8e Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Mon, 9 Feb 2026 14:15:01 -0500 Subject: [PATCH 04/16] ci: prep Windows signing hooks Add conditional cert import step and owner follow-up checklist. --- .github/workflows/publish.yml | 16 ++++++++++++++++ OWNER_FOLLOW.md | 11 +++++++++++ docs/breadcrumbs.md | 2 ++ docs/choices.md | 12 ++++++------ docs/specs/2026-02-09-windows-signing.md | 18 ++++++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 OWNER_FOLLOW.md create mode 100644 docs/specs/2026-02-09-windows-signing.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d252742a..916101eb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -110,6 +110,22 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain rm certificate.p12 + - name: Import Windows signing certificate + if: matrix.platform == 'windows-latest' && secrets.WINDOWS_CERTIFICATE != '' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $certDir = Join-Path $env:RUNNER_TEMP "windows-cert" + New-Item -ItemType Directory -Path $certDir | Out-Null + $certBase64 = Join-Path $certDir "cert.base64" + $certPfx = Join-Path $certDir "cert.pfx" + Set-Content -Path $certBase64 -Value $env:WINDOWS_CERTIFICATE -NoNewline + certutil -decode $certBase64 $certPfx | Out-Null + $securePassword = ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + Import-PfxCertificate -FilePath $certPfx -CertStoreLocation Cert:\CurrentUser\My -Password $securePassword | Out-Null + - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/OWNER_FOLLOW.md b/OWNER_FOLLOW.md new file mode 100644 index 00000000..64f40ede --- /dev/null +++ b/OWNER_FOLLOW.md @@ -0,0 +1,11 @@ +# Owner follow-up + +## Windows code signing +- Add GitHub secrets: + - `WINDOWS_CERTIFICATE`: base64-encoded PFX + - `WINDOWS_CERTIFICATE_PASSWORD`: PFX password +- Update `src-tauri/tauri.conf.json` with Windows signing config: + - `bundle.windows.certificateThumbprint` + - `bundle.windows.digestAlgorithm`: `sha256` + - `bundle.windows.timestampUrl`: trusted timestamp URL +- After secrets + config are set, Windows updater artifacts will be signed during publish. diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 1e79c46b..7fea67a2 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -11,3 +11,5 @@ - Windows OS gating: enable windows in plugin manifests and add actionable missing-path errors for testers. - Cleanup: moved `WINDOWS_CHANGES.md` and reserved `nul` file to trash; kept `src/contexts/taskbar-context.tsx` for later wiring. - Windows updater: documented that production updates require Authenticode signing; marked current state as test-only. +- Windows signing: added conditional PFX import step and owner follow-up checklist. +- Cleanup: removed unused `src/contexts/taskbar-context.tsx`. diff --git a/docs/choices.md b/docs/choices.md index 48b47a89..ae7ed252 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -74,14 +74,14 @@ This document records opinionated defaults chosen during development. **Decision:** Provide Windows-specific error strings that mention likely file locations (AppData/UserProfile) and ask testers to report actual paths. -### Keep Taskbar Context File For Follow-Up - -**Context:** `src/contexts/taskbar-context.tsx` exists but is not yet wired into the app. - -**Decision:** Keep the file in place (untracked for now) to integrate once taskbar state is finalized; avoid deleting to prevent churn while Windows support stabilizes. - ### Windows Auto-Update Requires Signing **Context:** Updater flow builds for Windows but CI has no Windows code signing. **Decision:** Document Windows auto-update as test-only until Authenticode signing is configured. + +### Remove Unused Taskbar Context + +**Context:** Taskbar position is already handled in `src/App.tsx` with Rust events and local state; `src/contexts/taskbar-context.tsx` was unused. + +**Decision:** Delete the unused context file to avoid dead code. diff --git a/docs/specs/2026-02-09-windows-signing.md b/docs/specs/2026-02-09-windows-signing.md new file mode 100644 index 00000000..07ae9d95 --- /dev/null +++ b/docs/specs/2026-02-09-windows-signing.md @@ -0,0 +1,18 @@ +2026-02-09 + +# Windows code signing (prep) + +## Goal +- Prepare CI to import a Windows signing certificate when secrets exist. + +## Non-goals +- Do not enable signing without owner-provided secrets. +- Do not add placeholder thumbprints to `tauri.conf.json`. + +## Plan +- Add a Windows-only CI step that imports a PFX from secrets into the user cert store. +- Document required secrets and `tauri.conf.json` fields for the owner. + +## Definition of done +- Publish workflow has a conditional Windows cert import step. +- `OWNER_FOLLOW.md` lists secrets + config the owner must set. From 6735290b174c8ca7ac5e83e203120db2173dfbf2 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Mon, 9 Feb 2026 15:25:12 -0500 Subject: [PATCH 05/16] fix: address PR77 review regressions Restore tray updates, Linux tray clicks, safer window clamp, and tighten env access. --- docs/breadcrumbs.md | 1 + docs/choices.md | 12 ++ docs/plugins/api.md | 1 + .../2026-02-09-pr77-code-review-fixes.md | 24 +++ plugins/claude/plugin.js | 76 +++----- plugins/codex/plugin.js | 45 ++--- plugins/copilot/plugin.js | 3 +- plugins/cursor/plugin.js | 26 +-- plugins/windsurf/plugin.js | 65 +++---- src-tauri/src/plugin_engine/host_api.rs | 9 +- src-tauri/src/tray.rs | 26 +++ src-tauri/src/window_manager.rs | 16 +- src/App.test.tsx | 166 +++++++++++++++--- src/App.tsx | 148 ++++++++++++---- 14 files changed, 410 insertions(+), 208 deletions(-) create mode 100644 docs/specs/2026-02-09-pr77-code-review-fixes.md diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 7fea67a2..cb473f2b 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -13,3 +13,4 @@ - Windows updater: documented that production updates require Authenticode signing; marked current state as test-only. - Windows signing: added conditional PFX import step and owner follow-up checklist. - Cleanup: removed unused `src/contexts/taskbar-context.tsx`. +- PR 77 fixes: re-enabled tray icon updates, restored Linux tray click handling, guarded window clamp, and replaced env-based Windows probes with `~`-based paths under a CODEX_HOME-only allowlist. diff --git a/docs/choices.md b/docs/choices.md index ae7ed252..db3d8e8f 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -85,3 +85,15 @@ This document records opinionated defaults chosen during development. **Context:** Taskbar position is already handled in `src/App.tsx` with Rust events and local state; `src/contexts/taskbar-context.tsx` was unused. **Decision:** Delete the unused context file to avoid dead code. + +### Restrict Env Allowlist To CODEX_HOME + +**Context:** Plugin host env access is intended to be minimal, and only `CODEX_HOME` is approved for exposure. + +**Decision:** Limit the env allowlist to `CODEX_HOME` and switch Windows path probes to `~`-based candidates instead of env vars. + +### Re-Enable Frontend Tray Icon Updates + +**Context:** Frontend settings/probe flows still call tray update hooks, but the update path was disabled, leaving the icon stale. + +**Decision:** Restore frontend tray icon rendering and updates on init/settings/probe to keep the tray icon consistent with state. diff --git a/docs/plugins/api.md b/docs/plugins/api.md index f8e4da65..01fad9f5 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -112,6 +112,7 @@ Reads an environment variable by name. - Returns variable value as string when set - Returns `null` when missing - Variable must be whitelisted first in `src-tauri/src/plugin_engine/host_api.rs` +- Currently only `CODEX_HOME` is whitelisted ### Example diff --git a/docs/specs/2026-02-09-pr77-code-review-fixes.md b/docs/specs/2026-02-09-pr77-code-review-fixes.md new file mode 100644 index 00000000..7b571c1b --- /dev/null +++ b/docs/specs/2026-02-09-pr77-code-review-fixes.md @@ -0,0 +1,24 @@ +2026-02-09 + +# PR 77 code review fixes + +## Goal +- Validate reported regressions and apply minimal fixes. + +## Issues +- Tray icon updates are no-op in frontend. +- Linux tray click no longer shows/hides window. +- Window position clamp can panic if window exceeds work area. +- Env allowlist exceeds stated minimal exposure (CODEX_HOME only). +- Claude plugin helper scopes cause ReferenceError. +- Windsurf plugin helper scopes leak to global. + +## Plan +- Re-enable tray icon updates via `renderTrayBarsIcon` and `TrayIcon.setIcon`. +- Restore Linux tray click handling to match Windows show/hide behavior. +- Guard clamp bounds when window size exceeds work area. +- Restrict env allowlist to `CODEX_HOME` and adjust plugin helpers accordingly. +- Move helper functions inside plugin IIFEs to restore correct scope. + +## Testing +- Not run (manual reasoning + compilation expected). diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index bf2c8413..8bb12e49 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -7,6 +7,32 @@ const SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration + function getWindowsCredentialCandidates() { + return [ + "~/.claude/.credentials.json", + "~/AppData/Roaming/Claude/.credentials.json", + "~/AppData/Local/Claude/.credentials.json", + ] + } + + function getCredentialsPath(ctx) { + if (ctx.app && ctx.app.platform === "windows") { + const candidates = getWindowsCredentialCandidates() + for (const path of candidates) { + if (ctx.host.fs.exists(path)) return path + } + + if (candidates.length > 0) return candidates[0] + } + + return CRED_FILE + } + + function isKeychainAvailable(ctx) { + if (!ctx.app) return false + return ctx.app.platform === "macos" || ctx.app.platform === "darwin" + } + function utf8DecodeBytes(bytes) { // Prefer native TextDecoder when available (QuickJS may not expose it). if (typeof TextDecoder !== "undefined") { @@ -289,7 +315,7 @@ if (!creds || !creds.oauth || !creds.oauth.accessToken || !creds.oauth.accessToken.trim()) { ctx.host.log.error("probe failed: not logged in") if (ctx.app && ctx.app.platform === "windows") { - const candidates = getWindowsCredentialCandidates(ctx) + const candidates = getWindowsCredentialCandidates() const preview = candidates.length > 0 ? candidates.slice(0, 3).join(", ") : "%USERPROFILE%\\.claude\\.credentials.json" @@ -421,51 +447,3 @@ globalThis.__openusage_plugin = { id: "claude", probe } })() - function getEnv(ctx, name) { - try { - if (!ctx.host.env || typeof ctx.host.env.get !== "function") return null - const value = ctx.host.env.get(name) - if (typeof value !== "string") return null - const trimmed = value.trim() - return trimmed || null - } catch (e) { - ctx.host.log.warn(name + " read failed: " + String(e)) - return null - } - } - - function getUserProfile(ctx) { - const userProfile = getEnv(ctx, "USERPROFILE") - if (userProfile) return userProfile - const homeDrive = getEnv(ctx, "HOMEDRIVE") - const homePath = getEnv(ctx, "HOMEPATH") - if (homeDrive && homePath) return homeDrive + homePath - return null - } - - function getCredentialsPath(ctx) { - if (ctx.app && ctx.app.platform === "windows") { - const candidates = getWindowsCredentialCandidates(ctx) - for (const path of candidates) { - if (ctx.host.fs.exists(path)) return path - } - - if (candidates.length > 0) return candidates[0] - } - - return CRED_FILE - } - - function getWindowsCredentialCandidates(ctx) { - const appData = getEnv(ctx, "APPDATA") - const localAppData = getEnv(ctx, "LOCALAPPDATA") - const userProfile = getUserProfile(ctx) - const candidates = [] - if (userProfile) candidates.push(userProfile + "\\.claude\\.credentials.json") - if (appData) candidates.push(appData + "\\Claude\\.credentials.json") - if (localAppData) candidates.push(localAppData + "\\Claude\\.credentials.json") - return candidates - } - function isKeychainAvailable(ctx) { - return ctx.app && ctx.app.platform === "macos" - } diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index 94a5a1ec..979e0649 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -11,46 +11,29 @@ return base.replace(/[\\/]+$/, "") + sep + leaf } - function getEnv(ctx, name) { + function getWindowsAuthPaths() { + const basePaths = [ + "~/AppData/Roaming/codex", + "~/AppData/Local/codex", + "~/.codex", + "~/.config/codex", + ] + return basePaths.map((base) => joinPath(base, AUTH_FILE)) + } + + function readCodexHome(ctx) { try { if (!ctx.host.env || typeof ctx.host.env.get !== "function") return null - const value = ctx.host.env.get(name) + const value = ctx.host.env.get("CODEX_HOME") if (typeof value !== "string") return null const trimmed = value.trim() return trimmed || null } catch (e) { - ctx.host.log.warn(name + " read failed: " + String(e)) + ctx.host.log.warn("CODEX_HOME read failed: " + String(e)) return null } } - function getUserProfile(ctx) { - const userProfile = getEnv(ctx, "USERPROFILE") - if (userProfile) return userProfile - const homeDrive = getEnv(ctx, "HOMEDRIVE") - const homePath = getEnv(ctx, "HOMEPATH") - if (homeDrive && homePath) return homeDrive + homePath - return null - } - - function getWindowsAuthPaths(ctx) { - const appData = getEnv(ctx, "APPDATA") - const localAppData = getEnv(ctx, "LOCALAPPDATA") - const userProfile = getUserProfile(ctx) - const basePaths = [] - if (appData) basePaths.push(joinPath(appData, "codex", "\\")) - if (localAppData) basePaths.push(joinPath(localAppData, "codex", "\\")) - if (userProfile) { - basePaths.push(joinPath(userProfile, ".codex", "\\")) - basePaths.push(joinPath(userProfile, ".config\\codex", "\\")) - } - return basePaths.map((base) => joinPath(base, AUTH_FILE, "\\")) - } - - function readCodexHome(ctx) { - return getEnv(ctx, "CODEX_HOME") - } - function resolveAuthPath(ctx) { const codexHome = readCodexHome(ctx) @@ -61,7 +44,7 @@ } if (ctx.app && ctx.app.platform === "windows") { - const windowsPaths = getWindowsAuthPaths(ctx) + const windowsPaths = getWindowsAuthPaths() for (const authPath of windowsPaths) { if (ctx.host.fs.exists(authPath)) return authPath } diff --git a/plugins/copilot/plugin.js b/plugins/copilot/plugin.js index fd81efcb..682d215c 100644 --- a/plugins/copilot/plugin.js +++ b/plugins/copilot/plugin.js @@ -4,7 +4,8 @@ const USAGE_URL = "https://api.github.com/copilot_internal/user"; function isKeychainAvailable(ctx) { - return ctx.app && ctx.app.platform === "macos"; + if (!ctx.app) return false; + return ctx.app.platform === "macos" || ctx.app.platform === "darwin"; } function readJson(ctx, path) { diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 883a9838..8d554ffb 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -16,32 +16,18 @@ return base + separator + leaf } - function getEnv(ctx, name) { - try { - return ctx.host.env.get(name) - } catch { - return null - } - } - function getStateDbPath(ctx) { if (ctx.app.platform === "windows") { - const appData = getEnv(ctx, "APPDATA") - const localAppData = getEnv(ctx, "LOCALAPPDATA") - const userProfile = getEnv(ctx, "USERPROFILE") - const candidates = [] - if (appData) candidates.push(joinPath(appData, "Cursor\\User", "\\")) - if (localAppData) candidates.push(joinPath(localAppData, "Cursor\\User", "\\")) - if (userProfile) { - candidates.push(joinPath(userProfile, "AppData\\Roaming\\Cursor\\User", "\\")) - candidates.push(joinPath(userProfile, "AppData\\Local\\Cursor\\User", "\\")) - } + const candidates = [ + "~/AppData/Roaming/Cursor/User", + "~/AppData/Local/Cursor/User", + ] for (const base of candidates) { - const dbPath = joinPath(base, "globalStorage\\state.vscdb", "\\") + const dbPath = joinPath(base, "globalStorage/state.vscdb", "/") if (ctx.host.fs.exists(dbPath)) return dbPath } if (candidates.length > 0) { - return joinPath(candidates[0], "globalStorage\\state.vscdb", "\\") + return joinPath(candidates[0], "globalStorage/state.vscdb", "/") } return null } diff --git a/plugins/windsurf/plugin.js b/plugins/windsurf/plugin.js index 0857ebc9..dbdcf606 100644 --- a/plugins/windsurf/plugin.js +++ b/plugins/windsurf/plugin.js @@ -3,6 +3,32 @@ var MAC_STATE_DB = "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb" var LINUX_STATE_DB = "~/.config/Windsurf/User/globalStorage/state.vscdb" + function joinPath(base, leaf, separator) { + if (!base) return leaf + if (base.endsWith("/") || base.endsWith("\\")) return base + leaf + return base + separator + leaf + } + + function getStateDbPath(ctx) { + if (ctx.app.platform === "windows") { + var candidates = [ + "~/AppData/Roaming/Windsurf/User", + "~/AppData/Local/Windsurf/User", + ] + for (var i = 0; i < candidates.length; i++) { + var dbPath = joinPath(candidates[i], "globalStorage/state.vscdb", "/") + if (ctx.host.fs.exists(dbPath)) return dbPath + } + if (candidates.length > 0) { + return joinPath(candidates[0], "globalStorage/state.vscdb", "/") + } + return null + } + + if (ctx.app.platform === "linux") return LINUX_STATE_DB + return MAC_STATE_DB + } + // --- LS discovery --- function discoverLs(ctx) { @@ -215,42 +241,3 @@ globalThis.__openusage_plugin = { id: "windsurf", probe: probe } })() - function joinPath(base, leaf, separator) { - if (!base) return leaf - if (base.endsWith("/") || base.endsWith("\\")) return base + leaf - return base + separator + leaf - } - - function getEnv(ctx, name) { - try { - return ctx.host.env.get(name) - } catch (e) { - return null - } - } - - function getStateDbPath(ctx) { - if (ctx.app.platform === "windows") { - var appData = getEnv(ctx, "APPDATA") - var localAppData = getEnv(ctx, "LOCALAPPDATA") - var userProfile = getEnv(ctx, "USERPROFILE") - var candidates = [] - if (appData) candidates.push(joinPath(appData, "Windsurf\\User", "\\")) - if (localAppData) candidates.push(joinPath(localAppData, "Windsurf\\User", "\\")) - if (userProfile) { - candidates.push(joinPath(userProfile, "AppData\\Roaming\\Windsurf\\User", "\\")) - candidates.push(joinPath(userProfile, "AppData\\Local\\Windsurf\\User", "\\")) - } - for (var i = 0; i < candidates.length; i++) { - var dbPath = joinPath(candidates[i], "globalStorage\\state.vscdb", "\\") - if (ctx.host.fs.exists(dbPath)) return dbPath - } - if (candidates.length > 0) { - return joinPath(candidates[0], "globalStorage\\state.vscdb", "\\") - } - return null - } - - if (ctx.app.platform === "linux") return LINUX_STATE_DB - return MAC_STATE_DB - } diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index b3390350..c938823b 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -1,14 +1,7 @@ use rquickjs::{Ctx, Exception, Function, Object}; use std::path::PathBuf; -const WHITELISTED_ENV_VARS: [&str; 6] = [ - "CODEX_HOME", - "APPDATA", - "LOCALAPPDATA", - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", -]; +const WHITELISTED_ENV_VARS: [&str; 1] = ["CODEX_HOME"]; /// Redact sensitive value to first4...last4 format (UTF-8 safe) fn redact_value(value: &str) -> String { diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 40b21be0..44d9bd5a 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -507,6 +507,32 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { let _ = window.set_focus(); } } + + #[cfg(target_os = "linux")] + { + // Linux: Use regular window + let window = app_handle.get_webview_window("main"); + + if let Some(window) = window { + if window.is_visible().unwrap_or(false) { + log::debug!("tray click: hiding window"); + let _ = window.hide(); + return; + } + + log::debug!("tray click: showing window"); + + // Position window near tray icon + if let (tauri::Position::Physical(pos), tauri::Size::Physical(size)) = + (rect.position, rect.size) + { + let _ = position_window_at_tray(&app_handle, pos, size); + } + + let _ = window.show(); + let _ = window.set_focus(); + } + } } } }) diff --git a/src-tauri/src/window_manager.rs b/src-tauri/src/window_manager.rs index 0b92d8c8..2bed0b07 100644 --- a/src-tauri/src/window_manager.rs +++ b/src-tauri/src/window_manager.rs @@ -305,8 +305,20 @@ fn calculate_window_position( }; // Clamp to work area bounds - let final_x = x.clamp(bounds_x, bounds_x + bounds_width - window_width); - let final_y = y.clamp(bounds_y, bounds_y + bounds_height - window_height); + let max_x = bounds_x + bounds_width - window_width; + let max_y = bounds_y + bounds_height - window_height; + + let final_x = if max_x < bounds_x { + bounds_x + } else { + x.clamp(bounds_x, max_x) + }; + + let final_y = if max_y < bounds_y { + bounds_y + } else { + y.clamp(bounds_y, max_y) + }; (final_x, final_y) } diff --git a/src/App.test.tsx b/src/App.test.tsx index 1db4d5ea..e6a2b737 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -7,7 +7,11 @@ const state = vi.hoisted(() => ({ invokeMock: vi.fn(), isTauriMock: vi.fn(() => false), setSizeMock: vi.fn(), + setPositionMock: vi.fn(), currentMonitorMock: vi.fn(), + outerSizeMock: vi.fn(async () => ({ width: 400, height: 500 })), + outerPositionMock: vi.fn(async () => ({ x: 0, y: 0 })), + onFocusChangedMock: vi.fn(async () => () => {}), startBatchMock: vi.fn(), savePluginSettingsMock: vi.fn(), loadPluginSettingsMock: vi.fn(), @@ -105,7 +109,13 @@ vi.mock("@tauri-apps/api/path", () => ({ })) vi.mock("@tauri-apps/api/window", () => ({ - getCurrentWindow: () => ({ setSize: state.setSizeMock }), + getCurrentWindow: () => ({ + setSize: state.setSizeMock, + setPosition: state.setPositionMock, + outerSize: state.outerSizeMock, + outerPosition: state.outerPositionMock, + onFocusChanged: state.onFocusChangedMock, + }), PhysicalSize: class { width: number height: number @@ -141,10 +151,105 @@ vi.mock("@/hooks/use-probe-events", () => ({ }, })) -vi.mock("@/lib/settings", async () => { - const actual = await vi.importActual("@/lib/settings") +vi.mock("@/lib/settings", () => { + const DEFAULT_AUTO_UPDATE_INTERVAL = 15 + const DEFAULT_THEME_MODE = "system" + const DEFAULT_DISPLAY_MODE = "left" + const DEFAULT_TRAY_ICON_STYLE = "bars" + const DEFAULT_TRAY_SHOW_PERCENTAGE = false + const REFRESH_COOLDOWN_MS = 300000 + const AUTO_UPDATE_OPTIONS = [ + { value: 5, label: "5 min" }, + { value: 15, label: "15 min" }, + { value: 30, label: "30 min" }, + { value: 60, label: "1 hour" }, + ] + const THEME_OPTIONS = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ] + const DISPLAY_MODE_OPTIONS = [ + { value: "left", label: "Left" }, + { value: "used", label: "Used" }, + ] + const TRAY_ICON_STYLE_OPTIONS = [ + { value: "bars", label: "Bars" }, + { value: "circle", label: "Circle" }, + { value: "provider", label: "Provider" }, + { value: "textOnly", label: "%" }, + ] + const DEFAULT_ENABLED_PLUGINS = new Set(["claude", "codex", "cursor"]) + + function isTrayPercentageMandatory(style: "bars" | "circle" | "provider" | "textOnly") { + return style === "provider" || style === "textOnly" + } + + function normalizePluginSettings( + settings: { order: string[]; disabled: string[] }, + plugins: { id: string }[] + ) { + const knownIds = plugins.map((plugin) => plugin.id) + const knownSet = new Set(knownIds) + const order: string[] = [] + const seen = new Set() + for (const id of settings.order) { + if (!knownSet.has(id) || seen.has(id)) continue + seen.add(id) + order.push(id) + } + const newlyAdded: string[] = [] + for (const id of knownIds) { + if (!seen.has(id)) { + seen.add(id) + order.push(id) + newlyAdded.push(id) + } + } + const disabled = settings.disabled.filter((id) => knownSet.has(id)) + for (const id of newlyAdded) { + if (!DEFAULT_ENABLED_PLUGINS.has(id) && !disabled.includes(id)) { + disabled.push(id) + } + } + return { order, disabled } + } + + function arePluginSettingsEqual( + a: { order: string[]; disabled: string[] }, + b: { order: string[]; disabled: string[] } + ) { + if (a.order.length !== b.order.length) return false + if (a.disabled.length !== b.disabled.length) return false + for (let i = 0; i < a.order.length; i += 1) { + if (a.order[i] !== b.order[i]) return false + } + for (let i = 0; i < a.disabled.length; i += 1) { + if (a.disabled[i] !== b.disabled[i]) return false + } + return true + } + + function getEnabledPluginIds(settings: { order: string[]; disabled: string[] }) { + const disabledSet = new Set(settings.disabled) + return settings.order.filter((id) => !disabledSet.has(id)) + } + return { - ...actual, + DEFAULT_AUTO_UPDATE_INTERVAL, + DEFAULT_THEME_MODE, + DEFAULT_DISPLAY_MODE, + DEFAULT_TRAY_ICON_STYLE, + DEFAULT_TRAY_SHOW_PERCENTAGE, + REFRESH_COOLDOWN_MS, + AUTO_UPDATE_OPTIONS, + THEME_OPTIONS, + DISPLAY_MODE_OPTIONS, + TRAY_ICON_STYLE_OPTIONS, + arePluginSettingsEqual, + normalizePluginSettings, + getEnabledPluginIds, + isTrayPercentageMandatory, loadPluginSettings: state.loadPluginSettingsMock, savePluginSettings: state.savePluginSettingsMock, loadAutoUpdateInterval: state.loadAutoUpdateIntervalMock, @@ -164,12 +269,19 @@ import { App } from "@/App" describe("App", () => { beforeEach(() => { + if (typeof HTMLElement === "undefined") { + Object.defineProperty(globalThis, "HTMLElement", { value: class {}, configurable: true }) + } state.probeHandlers = null state.invokeMock.mockReset() state.isTauriMock.mockReset() state.isTauriMock.mockReturnValue(false) state.setSizeMock.mockReset() + state.setPositionMock.mockReset() state.currentMonitorMock.mockReset() + state.outerSizeMock.mockReset() + state.outerPositionMock.mockReset() + state.onFocusChangedMock.mockReset() state.startBatchMock.mockReset() state.savePluginSettingsMock.mockReset() state.loadPluginSettingsMock.mockReset() @@ -220,8 +332,8 @@ describe("App", () => { state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { return [ - { id: "a", name: "Alpha", iconUrl: "icon-a", primaryProgressLabel: null, lines: [{ type: "text", label: "Now", scope: "overview" }] }, - { id: "b", name: "Beta", iconUrl: "icon-b", primaryProgressLabel: null, lines: [] }, + { id: "a", name: "Alpha", iconUrl: "icon-a", primaryCandidates: [], lines: [{ type: "text", label: "Now", scope: "overview" }] }, + { id: "b", name: "Beta", iconUrl: "icon-b", primaryCandidates: [], lines: [] }, ] } return null @@ -292,9 +404,9 @@ describe("App", () => { if (cmd === "list_plugins") { return [ { - id: "a", - name: "Alpha", - iconUrl: "icon-a", + id: "claude", + name: "Claude", + iconUrl: "icon-claude", primaryCandidates: ["Session"], lines: [{ type: "progress", label: "Session", scope: "overview" }], }, @@ -302,7 +414,7 @@ describe("App", () => { } return null }) - state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["claude"], disabled: [] }) render() await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) @@ -312,9 +424,9 @@ describe("App", () => { const callsBefore = state.renderTrayBarsIconMock.mock.calls.length state.probeHandlers?.onResult({ - providerId: "a", - displayName: "Alpha", - iconUrl: "icon-a", + providerId: "claude", + displayName: "Claude", + iconUrl: "icon-claude", lines: [{ type: "progress", label: "Session", used: 50, limit: 100, format: { kind: "percent" } }], }) @@ -451,9 +563,9 @@ describe("App", () => { if (cmd === "list_plugins") { return [ { - id: "a", - name: "Alpha", - iconUrl: "icon-a", + id: "claude", + name: "Claude", + iconUrl: "icon-claude", primaryCandidates: ["Session"], lines: [{ type: "progress", label: "Session", scope: "overview" }], }, @@ -461,7 +573,7 @@ describe("App", () => { } return null }) - state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["claude"], disabled: [] }) render() await waitFor(() => expect(state.renderTrayBarsIconMock).toHaveBeenCalled()) @@ -1055,21 +1167,21 @@ describe("App", () => { window.requestAnimationFrame = rafSpy // Setup plugin with primary progress - state.invokeMock.mockImplementationOnce(async (cmd: string) => { + state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { return [ { - id: "a", - name: "Alpha", - iconUrl: "icon-a", - primaryProgressLabel: "Session", + id: "claude", + name: "Claude", + iconUrl: "icon-claude", + primaryCandidates: ["Session"], lines: [{ type: "progress", label: "Session", scope: "overview" }], }, ] } return null }) - state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["claude"], disabled: [] }) render() await vi.waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) @@ -1082,9 +1194,9 @@ describe("App", () => { // Trigger a probe result state.probeHandlers?.onResult({ - providerId: "a", - displayName: "Alpha", - iconUrl: "icon-a", + providerId: "claude", + displayName: "Claude", + iconUrl: "icon-claude", lines: [{ type: "progress", label: "Session", used: 50, limit: 100, format: { kind: "percent" } }], }) @@ -1093,7 +1205,7 @@ describe("App", () => { // Tray icon should have been updated even though requestAnimationFrame was never called expect(rafSpy).not.toHaveBeenCalled() - expect(state.traySetIconMock).toHaveBeenCalled() + await vi.waitFor(() => expect(state.traySetIconMock).toHaveBeenCalled()) window.requestAnimationFrame = originalRaf vi.useRealTimers() diff --git a/src/App.tsx b/src/App.tsx index 3d226373..a130e3b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event" import { getCurrentWindow, PhysicalSize, PhysicalPosition, currentMonitor } from "@tauri-apps/api/window" import { getVersion } from "@tauri-apps/api/app" import { TrayIcon } from "@tauri-apps/api/tray" +import { resolveResource } from "@tauri-apps/api/path" import { platform } from "@tauri-apps/plugin-os" import { SideNav, type ActiveView } from "@/components/side-nav" import { PanelFooter } from "@/components/panel-footer" @@ -11,9 +12,8 @@ import { OverviewPage } from "@/pages/overview" import { ProviderDetailPage } from "@/pages/provider-detail" import { SettingsPage } from "@/pages/settings" import type { PluginMeta, PluginOutput } from "@/lib/plugin-types" -// Tray icon updates disabled - backend handles it -// import { getTrayIconSizePx, renderTrayBarsIcon } from "@/lib/tray-bars-icon" -// import { getTrayPrimaryBars } from "@/lib/tray-primary-progress" +import { getTrayIconSizePx, renderTrayBarsIcon } from "@/lib/tray-bars-icon" +import { getTrayPrimaryBars } from "@/lib/tray-primary-progress" import { useProbeEvents } from "@/hooks/use-probe-events" import { useAppUpdate } from "@/hooks/use-app-update" import { @@ -91,8 +91,9 @@ function App() { const { updateStatus, triggerInstall } = useAppUpdate() const [showAbout, setShowAbout] = useState(false) - // Tray icon managed by backend - no frontend refs needed + // Tray icon handle for frontend updates const trayRef = useRef(null) + const trayUpdateTimeoutRef = useRef | null>(null) // Store state in refs so scheduleTrayIconUpdate can read current values without recreating the callback const pluginsMetaRef = useRef(pluginsMeta) @@ -120,7 +121,91 @@ function App() { } }, []) - // Initialize tray handle once - just get reference, don't update icon + const [isTrayReady, setIsTrayReady] = useState(false) + const scheduleTrayIconUpdate = useCallback((reason: "probe" | "settings" | "init", delayMs = 0) => { + if (trayUpdateTimeoutRef.current !== null) { + clearTimeout(trayUpdateTimeoutRef.current) + } + + trayUpdateTimeoutRef.current = setTimeout(async () => { + trayUpdateTimeoutRef.current = null + const currentSettings = pluginSettingsRef.current + const currentMeta = pluginsMetaRef.current + if (!currentSettings || currentMeta.length === 0) return + + const style = trayIconStyleRef.current + const maxBars = style === "bars" ? 4 : 1 + const bars = getTrayPrimaryBars({ + pluginsMeta: currentMeta, + pluginSettings: currentSettings, + pluginStates: pluginStatesRef.current, + displayMode: displayModeRef.current, + maxBars, + }) + if (bars.length === 0) return + const shouldShowPercentage = isTrayPercentageMandatory(style) + ? true + : trayShowPercentageRef.current + const primaryFraction = bars[0]?.fraction + const percentText = + shouldShowPercentage && typeof primaryFraction === "number" + ? `${Math.round(primaryFraction * 100)}%` + : undefined + const providerIconUrl = + style === "provider" + ? currentMeta.find((plugin) => plugin.id === bars[0]?.id)?.iconUrl + : undefined + const dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1 + const sizePx = getTrayIconSizePx(dpr) + + try { + const image = await renderTrayBarsIcon({ + bars, + sizePx, + style, + percentText, + providerIconUrl, + }) + let tray = trayRef.current + if (!tray) { + tray = await TrayIcon.getById("tray").catch(() => null) + if (tray) trayRef.current = tray + } + if (tray) { + await tray.setIcon(image) + } + } catch (error) { + console.error(`Failed to update tray icon (${reason}):`, error) + } + }, delayMs) + }, []) + + useEffect(() => { + if (!isTrayReady) return + if (!pluginSettings || pluginsMeta.length === 0) return + scheduleTrayIconUpdate("init", 0) + }, [isTrayReady, pluginSettings, pluginsMeta, scheduleTrayIconUpdate]) + + useEffect(() => { + let cancelled = false + resolveResource("icons/tray-icon.png").catch((error) => { + if (cancelled) return + console.error("Failed to resolve tray icon resource:", error) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + return () => { + if (trayUpdateTimeoutRef.current !== null) { + clearTimeout(trayUpdateTimeoutRef.current) + } + } + }, []) + + // Initialize tray handle once const trayInitializedRef = useRef(false) useEffect(() => { if (trayInitializedRef.current) return @@ -131,6 +216,7 @@ function App() { if (cancelled) return trayRef.current = tray trayInitializedRef.current = true + setIsTrayReady(true) } catch (e) { console.error("Failed to load tray icon handle:", e) } @@ -140,14 +226,6 @@ function App() { } }, []) - // Tray icon updates disabled - backend sets icon once on startup - const scheduleTrayIconUpdate = useCallback((_reason: "probe" | "settings" | "init", _delayMs = 0) => { - // Icon updates disabled to prevent frontend from overriding backend icon - }, []) - - // Don't update tray icon on init - backend already set the correct icon - // Only update when we have actual probe results (handled by handleProbeResult) - const displayPlugins = useMemo(() => { if (!pluginSettings) return [] @@ -394,25 +472,25 @@ function App() { }, []) const setLoadingForPlugins = useCallback((ids: string[]) => { - setPluginStates((prev) => { - const next = { ...prev } - for (const id of ids) { - const existing = prev[id] - next[id] = { data: null, loading: true, error: null, lastManualRefreshAt: existing?.lastManualRefreshAt ?? null } - } - return next - }) + const prev = pluginStatesRef.current + const next = { ...prev } + for (const id of ids) { + const existing = prev[id] + next[id] = { data: null, loading: true, error: null, lastManualRefreshAt: existing?.lastManualRefreshAt ?? null } + } + pluginStatesRef.current = next + setPluginStates(next) }, []) const setErrorForPlugins = useCallback((ids: string[], error: string) => { - setPluginStates((prev) => { - const next = { ...prev } - for (const id of ids) { - const existing = prev[id] - next[id] = { data: null, loading: false, error, lastManualRefreshAt: existing?.lastManualRefreshAt ?? null } - } - return next - }) + const prev = pluginStatesRef.current + const next = { ...prev } + for (const id of ids) { + const existing = prev[id] + next[id] = { data: null, loading: false, error, lastManualRefreshAt: existing?.lastManualRefreshAt ?? null } + } + pluginStatesRef.current = next + setPluginStates(next) }, []) // Track which plugin IDs are being manually refreshed (vs initial load / enable toggle) @@ -425,7 +503,8 @@ function App() { if (isManual) { manualRefreshIdsRef.current.delete(output.providerId) } - setPluginStates((prev) => ({ + const prev = pluginStatesRef.current + const next = { ...prev, [output.providerId]: { data: errorMessage ? null : output, @@ -436,7 +515,9 @@ function App() { ? Date.now() : (prev[output.providerId]?.lastManualRefreshAt ?? null), }, - })) + } + pluginStatesRef.current = next + setPluginStates(next) // Regenerate tray icon on every probe result (debounced to avoid churn). scheduleTrayIconUpdate("probe", TRAY_PROBE_DEBOUNCE_MS) @@ -459,6 +540,7 @@ function App() { const availablePlugins = await invoke("list_plugins") if (!isMounted) return setPluginsMeta(availablePlugins) + pluginsMetaRef.current = availablePlugins const storedSettings = await loadPluginSettings() const normalized = normalizePluginSettings( @@ -511,11 +593,15 @@ function App() { if (isMounted) { setPluginSettings(normalized) + pluginSettingsRef.current = normalized setAutoUpdateInterval(storedInterval) setThemeMode(storedThemeMode) setDisplayMode(storedDisplayMode) + displayModeRef.current = storedDisplayMode setTrayIconStyle(storedTrayIconStyle) + trayIconStyleRef.current = storedTrayIconStyle setTrayShowPercentage(normalizedTrayShowPercentage) + trayShowPercentageRef.current = normalizedTrayShowPercentage const enabledIds = getEnabledPluginIds(normalized) setLoadingForPlugins(enabledIds) try { From d55c20b1c1c966f39834cf65ed07dc1fda5a8aa3 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Tue, 10 Feb 2026 17:59:46 -0500 Subject: [PATCH 06/16] fix: embed sqlite for plugin host --- docs/breadcrumbs.md | 5 ++ docs/choices.md | 13 ++++ src-tauri/Cargo.toml | 1 + src-tauri/src/plugin_engine/host_api.rs | 94 +++++++++++++++++-------- 4 files changed, 85 insertions(+), 28 deletions(-) diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index cb473f2b..780b9dfa 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -14,3 +14,8 @@ - Windows signing: added conditional PFX import step and owner follow-up checklist. - Cleanup: removed unused `src/contexts/taskbar-context.tsx`. - PR 77 fixes: re-enabled tray icon updates, restored Linux tray click handling, guarded window clamp, and replaced env-based Windows probes with `~`-based paths under a CODEX_HOME-only allowlist. + +## 2026-02-10 + +- Verified sqlite access uses external `sqlite3` CLI in `src-tauri/src/plugin_engine/host_api.rs` and no bundled sqlite binary/resources in `src-tauri/tauri.conf.json`. +- Switched plugin host sqlite to embedded `rusqlite` with bundled SQLite for cross-platform availability. diff --git a/docs/choices.md b/docs/choices.md index db3d8e8f..df16f5ca 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -97,3 +97,16 @@ This document records opinionated defaults chosen during development. **Context:** Frontend settings/probe flows still call tray update hooks, but the update path was disabled, leaving the icon stale. **Decision:** Restore frontend tray icon rendering and updates on init/settings/probe to keep the tray icon consistent with state. + +## 2026-02-10 + +### Embed SQLite Instead Of External CLI + +**Context:** Plugin host `sqlite` API used `sqlite3` CLI, which is missing on clean Windows machines. + +**Decision:** Use `rusqlite` with the `bundled` feature so SQLite is embedded in the app; remove `sqlite3` process calls. + +**Technical details:** +- Read-only queries open `file:...?...immutable=1` with `SQLITE_OPEN_URI`. +- Writes use `SQLITE_OPEN_READ_WRITE | SQLITE_OPEN_CREATE` and `execute_batch`. +- Blob columns serialize to base64 strings to keep JSON output stable. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 815cc447..b817026f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } regex-lite = "0.1.9" +rusqlite = { version = "0.33", features = ["bundled"] } [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index c938823b..78e1d307 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -1,4 +1,8 @@ +use base64::Engine; use rquickjs::{Ctx, Exception, Function, Object}; +use rusqlite::types::ValueRef; +use rusqlite::{Connection, OpenFlags, Row}; +use serde_json::{Number, Value}; use std::path::PathBuf; const WHITELISTED_ENV_VARS: [&str; 1] = ["CODEX_HOME"]; @@ -1251,22 +1255,33 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() .replace('#', "%23") .replace('?', "%3F"); let uri_path = format!("file:{}?immutable=1", encoded); - let output = std::process::Command::new("sqlite3") - .args(["-readonly", "-json", &uri_path, &sql]) - .output() - .map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) - })?; + let conn = Connection::open_with_flags( + &uri_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite open failed: {}", e)) + })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(Exception::throw_message( - &ctx_inner, - &format!("sqlite3 error: {}", stderr.trim()), - )); + let mut stmt = conn.prepare(&sql).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite prepare failed: {}", e)) + })?; + + let rows = stmt.query_map([], sqlite_row_to_json).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite query failed: {}", e)) + })?; + + let mut result: Vec = Vec::new(); + for row in rows { + let value = row.map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite row failed: {}", e)) + })?; + result.push(value); } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) + serde_json::to_string(&result).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite json failed: {}", e)) + }) }, )?, )?; @@ -1283,22 +1298,17 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() )); } let expanded = expand_path(&db_path); - let output = std::process::Command::new("sqlite3") - .args([&expanded, &sql]) - .output() - .map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(Exception::throw_message( - &ctx_inner, - &format!("sqlite3 error: {}", stderr.trim()), - )); - } + let conn = Connection::open_with_flags( + &expanded, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + ) + .map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite open failed: {}", e)) + })?; - Ok(()) + conn.execute_batch(&sql).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite exec failed: {}", e)) + }) }, )?, )?; @@ -1307,6 +1317,34 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() Ok(()) } +fn sqlite_row_to_json(row: &Row<'_>) -> rusqlite::Result { + let mut obj = serde_json::Map::new(); + let col_count = row.column_count(); + for i in 0..col_count { + let name = match row.column_name(i) { + Ok(n) => n.to_string(), + Err(_) => format!("col{}", i), + }; + let value = sqlite_value_to_json(row.get_ref(i)?); + obj.insert(name, value); + } + Ok(Value::Object(obj)) +} + +fn sqlite_value_to_json(value: ValueRef<'_>) -> Value { + match value { + ValueRef::Null => Value::Null, + ValueRef::Integer(v) => Value::Number(Number::from(v)), + ValueRef::Real(v) => Number::from_f64(v) + .map(Value::Number) + .unwrap_or(Value::Null), + ValueRef::Text(bytes) => Value::String(String::from_utf8_lossy(bytes).to_string()), + ValueRef::Blob(bytes) => { + Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)) + } + } +} + fn iso_now() -> String { time::OffsetDateTime::now_utc() .format(&time::format_description::well_known::Rfc3339) From 29e5395135d055f90f628c96303102b5cd82a2d8 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Tue, 10 Feb 2026 18:05:42 -0500 Subject: [PATCH 07/16] fix: replace wmic process listing --- docs/breadcrumbs.md | 1 + docs/choices.md | 10 +++ src-tauri/Cargo.lock | 64 ++++++++++++++- src-tauri/src/plugin_engine/host_api.rs | 100 +++++++++++++++--------- src-tauri/src/plugin_engine/runtime.rs | 1 + 5 files changed, 136 insertions(+), 40 deletions(-) diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 780b9dfa..541dce4f 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -19,3 +19,4 @@ - Verified sqlite access uses external `sqlite3` CLI in `src-tauri/src/plugin_engine/host_api.rs` and no bundled sqlite binary/resources in `src-tauri/tauri.conf.json`. - Switched plugin host sqlite to embedded `rusqlite` with bundled SQLite for cross-platform availability. +- Replaced Windows process discovery `wmic` with PowerShell CIM JSON parsing in `src-tauri/src/plugin_engine/host_api.rs`. diff --git a/docs/choices.md b/docs/choices.md index df16f5ca..ecc803f4 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -110,3 +110,13 @@ This document records opinionated defaults chosen during development. - Read-only queries open `file:...?...immutable=1` with `SQLITE_OPEN_URI`. - Writes use `SQLITE_OPEN_READ_WRITE | SQLITE_OPEN_CREATE` and `execute_batch`. - Blob columns serialize to base64 strings to keep JSON output stable. + +### Replace WMIC With PowerShell CIM + +**Context:** Windows LS discovery used `wmic`, which is deprecated and scheduled for removal. + +**Decision:** Use PowerShell `Get-CimInstance Win32_Process` with `ConvertTo-Json` for process listings. + +**Technical details:** +- Force UTF-8 output via `[Console]::OutputEncoding`. +- Parse JSON into `{ ProcessId, CommandLine }` entries; skip null command lines. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 02886340..47379cab 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1124,6 +1124,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1191,6 +1203,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1714,6 +1732,15 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1722,7 +1749,16 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", ] [[package]] @@ -2294,6 +2330,17 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2913,6 +2960,7 @@ dependencies = [ "regex-lite", "reqwest 0.13.2", "rquickjs", + "rusqlite", "serde", "serde_json", "tauri", @@ -3880,6 +3928,20 @@ dependencies = [ "cc", ] +[[package]] +name = "rusqlite" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.40.0" diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 78e1d307..4994dfa4 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -782,13 +782,14 @@ fn inject_ls<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquick // Platform-specific process listing let ps_output = if cfg!(target_os = "windows") { - match std::process::Command::new("wmic") - .args(["process", "get", "ProcessId,CommandLine", "/format:list"]) + const WINDOWS_PS_CMD: &str = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::UTF8; Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"; + match std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", WINDOWS_PS_CMD]) .output() { Ok(o) => o, Err(e) => { - log::warn!("[plugin:{}] wmic failed: {}", pid, e); + log::warn!("[plugin:{}] powershell process listing failed: {}", pid, e); return Ok("null".to_string()); } } @@ -821,41 +822,26 @@ fn inject_ls<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquick let mut found: Option<(i32, String)> = None; if cfg!(target_os = "windows") { - // Parse wmic output: key=value pairs separated by blank lines - let mut current_pid: Option = None; - let mut current_command: Option = None; + let entries = match ls_windows_process_list(&ps_output.stdout) { + Ok(list) => list, + Err(err) => { + log::warn!("[plugin:{}] powershell parse failed: {}", pid, err); + return Ok("null".to_string()); + } + }; - for line in ps_stdout.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - // End of record - check if it matches - if let (Some(pid), Some(cmd)) = (current_pid, ¤t_command) { - let cmd_lower = cmd.to_lowercase(); - if cmd_lower.contains(&process_name_lower) { - let has_marker = markers_lower.iter().any(|m| { - cmd_lower.contains(&format!("--app_data_dir {}", m)) - || cmd_lower.contains(&format!("\\{}\\", m)) - }); - if has_marker { - found = Some((pid, cmd.clone())); - break; - } - } - } - current_pid = None; - current_command = None; + for (pid, command) in entries { + let cmd_lower = command.to_lowercase(); + if !cmd_lower.contains(&process_name_lower) { continue; } - - if let Some(eq_pos) = trimmed.find('=') { - let key = &trimmed[..eq_pos]; - let value = &trimmed[eq_pos + 1..]; - - if key == "ProcessId" { - current_pid = value.parse::().ok(); - } else if key == "CommandLine" { - current_command = Some(value.to_string()); - } + let has_marker = markers_lower.iter().any(|m| { + cmd_lower.contains(&format!("--app_data_dir {}", m)) + || cmd_lower.contains(&format!("\\{}\\", m)) + }); + if has_marker { + found = Some((pid, command)); + break; } } } else { @@ -1057,6 +1043,39 @@ fn ls_extract_flag(command: &str, flag: &str) -> Option { None } +#[derive(serde::Deserialize)] +struct WindowsProcessEntry { + #[serde(rename = "ProcessId")] + process_id: i32, + #[serde(rename = "CommandLine")] + command_line: Option, +} + +fn ls_windows_process_list(output: &[u8]) -> Result, String> { + if output.iter().all(|b| b.is_ascii_whitespace()) { + return Ok(Vec::new()); + } + + let value: serde_json::Value = + serde_json::from_slice(output).map_err(|e| format!("invalid JSON: {}", e))?; + + let entries: Vec = match value { + serde_json::Value::Array(items) => items + .into_iter() + .map(|item| serde_json::from_value(item).map_err(|e| format!("invalid entry: {}", e))) + .collect::, _>>()?, + serde_json::Value::Object(_) => { + vec![serde_json::from_value(value).map_err(|e| format!("invalid entry: {}", e))?] + } + _ => return Err("unexpected JSON shape".to_string()), + }; + + Ok(entries + .into_iter() + .filter_map(|entry| entry.command_line.map(|cmd| (entry.process_id, cmd))) + .collect()) +} + /// Parse listening port numbers from `lsof -nP -iTCP -sTCP:LISTEN` output. fn ls_parse_listening_ports(output: &str) -> Vec { let mut ports = std::collections::BTreeSet::new(); @@ -1319,11 +1338,14 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() fn sqlite_row_to_json(row: &Row<'_>) -> rusqlite::Result { let mut obj = serde_json::Map::new(); - let col_count = row.column_count(); + let stmt = row.as_ref(); + let col_count = stmt.column_count(); for i in 0..col_count { - let name = match row.column_name(i) { - Ok(n) => n.to_string(), - Err(_) => format!("col{}", i), + let name = stmt.column_name(i).unwrap_or(""); + let name = if name.is_empty() { + format!("col{}", i) + } else { + name.to_string() }; let value = sqlite_value_to_json(row.get_ref(i)?); obj.insert(name, value); diff --git a/src-tauri/src/plugin_engine/runtime.rs b/src-tauri/src/plugin_engine/runtime.rs index 1acb58a2..20efe936 100644 --- a/src-tauri/src/plugin_engine/runtime.rs +++ b/src-tauri/src/plugin_engine/runtime.rs @@ -484,6 +484,7 @@ mod tests { icon: "icon.svg".to_string(), brand_color: None, lines: vec![], + os: None, }, plugin_dir: PathBuf::from("."), entry_script: entry_script.to_string(), From 7217db49fbbf843b5aa991c9dd75240c7a15ea8a Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Tue, 10 Feb 2026 18:20:19 -0500 Subject: [PATCH 08/16] fix: add Windows DPAPI vault --- docs/breadcrumbs.md | 1 + docs/choices.md | 10 ++ docs/plugins/api.md | 27 ++++ docs/providers/copilot.md | 7 +- docs/specs/2026-02-10-windows-vault-dpapi.md | 31 +++++ plugins/copilot/plugin.js | 137 ++++++++++++++----- plugins/copilot/plugin.test.js | 25 ++-- plugins/test-helpers.js | 6 + src-tauri/Cargo.lock | 12 ++ src-tauri/Cargo.toml | 3 + src-tauri/src/plugin_engine/host_api.rs | 135 ++++++++++++++++++ 11 files changed, 340 insertions(+), 54 deletions(-) create mode 100644 docs/specs/2026-02-10-windows-vault-dpapi.md diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 541dce4f..0cb3bb9b 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -20,3 +20,4 @@ - Verified sqlite access uses external `sqlite3` CLI in `src-tauri/src/plugin_engine/host_api.rs` and no bundled sqlite binary/resources in `src-tauri/tauri.conf.json`. - Switched plugin host sqlite to embedded `rusqlite` with bundled SQLite for cross-platform availability. - Replaced Windows process discovery `wmic` with PowerShell CIM JSON parsing in `src-tauri/src/plugin_engine/host_api.rs`. +- Added Windows DPAPI-backed `host.vault` and updated Copilot auth to use it. diff --git a/docs/choices.md b/docs/choices.md index ecc803f4..08f225b8 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -120,3 +120,13 @@ This document records opinionated defaults chosen during development. **Technical details:** - Force UTF-8 output via `[Console]::OutputEncoding`. - Parse JSON into `{ ProcessId, CommandLine }` entries; skip null command lines. + +### Add Windows Vault (DPAPI) + +**Context:** Windows token storage relied on plaintext files when keychain was unavailable. + +**Decision:** Add `host.vault` backed by DPAPI user-scope encryption and use it for Copilot token caching on Windows. + +**Technical details:** +- Encrypted bytes are stored as base64 under `appDataDir/vault/`. +- `host.vault` throws on non-Windows platforms. diff --git a/docs/plugins/api.md b/docs/plugins/api.md index 01fad9f5..1bf05795 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -215,6 +215,33 @@ if (ctx.host.fs.exists("~/.myapp/credentials.json")) { } ``` +## Vault (Windows only) + +```typescript +host.vault.read(name: string): string | null +host.vault.write(name: string, value: string): void +host.vault.delete(name: string): void +``` + +DPAPI-backed storage for sensitive values on Windows. + +### Behavior + +- **Windows only**: Throws on other platforms +- **User scope**: Encrypted data is tied to the current Windows user +- **Returns null if missing**: `read` returns `null` when no value is stored + +### Example + +```javascript +const key = "my-plugin:token" +const raw = ctx.host.vault.read(key) +if (!raw) throw "Login required." + +const parsed = JSON.parse(raw) +ctx.host.vault.write(key, JSON.stringify({ token: parsed.token })) +``` + ## SQLite ### Query (Read-Only) diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index 5cf0354f..ddefe5d6 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -6,9 +6,10 @@ Tracks GitHub Copilot usage quotas for both paid and free tier users. The plugin looks for a GitHub token in this order: -1. **OpenUsage Keychain** (`OpenUsage-copilot`) — Token previously cached by the plugin -2. **GitHub CLI Keychain** (`gh:github.com`) — Token from `gh auth login` -3. **State File** (`auth.json`) — Fallback file-based storage +1. **OpenUsage Vault (Windows)** (`copilot:token`) — DPAPI-encrypted cache +2. **OpenUsage Keychain (macOS)** (`OpenUsage-copilot`) — Token previously cached by the plugin +3. **GitHub CLI Keychain (macOS)** (`gh:github.com`) — Token from `gh auth login` +4. **GitHub CLI config file (Windows)** (`hosts.yml`) — Fallback when keychain is unavailable ### Setup diff --git a/docs/specs/2026-02-10-windows-vault-dpapi.md b/docs/specs/2026-02-10-windows-vault-dpapi.md new file mode 100644 index 00000000..d8f77faa --- /dev/null +++ b/docs/specs/2026-02-10-windows-vault-dpapi.md @@ -0,0 +1,31 @@ +# Windows Vault (DPAPI) + +## Goal +- Protect OpenUsage-managed tokens on Windows using DPAPI instead of plaintext files. + +## Scope +- Add `host.vault` API (read/write/delete) backed by DPAPI user-scope encryption. +- Store vault entries under `appDataDir/vault/`. +- Update Copilot plugin to use vault on Windows. +- Document vault API and Copilot auth order. + +## Non-Goals +- Replace provider-owned credential files (Claude/Codex). +- Integrate with Windows Credential Manager for third-party secrets. +- Add UI for manual token entry. + +## Approach +- Use `windows-dpapi` crate (`encrypt_data`/`decrypt_data`, `Scope::User`). +- Base64-encode encrypted bytes for storage on disk. +- Encode vault key names using URL-safe base64 to avoid filesystem issues. + +## Testing +- `cargo test` (src-tauri) +- `bun test plugins/copilot/plugin.test.js` + +## Risks +- DPAPI ties data to user profile; tokens are not portable across machines. +- gh CLI token access on Windows depends on whether `hosts.yml` contains `oauth_token`. + +## Open Questions +- None. diff --git a/plugins/copilot/plugin.js b/plugins/copilot/plugin.js index 682d215c..5eabba07 100644 --- a/plugins/copilot/plugin.js +++ b/plugins/copilot/plugin.js @@ -1,6 +1,7 @@ (function () { const KEYCHAIN_SERVICE = "OpenUsage-copilot"; const GH_KEYCHAIN_SERVICE = "gh:github.com"; + const VAULT_KEY = "copilot:token"; const USAGE_URL = "https://api.github.com/copilot_internal/user"; function isKeychainAvailable(ctx) { @@ -8,26 +9,25 @@ return ctx.app.platform === "macos" || ctx.app.platform === "darwin"; } - function readJson(ctx, path) { - try { - if (!ctx.host.fs.exists(path)) return null; - const text = ctx.host.fs.readText(path); - return ctx.util.tryParseJson(text); - } catch (e) { - ctx.host.log.warn("readJson failed for " + path + ": " + String(e)); - return null; - } + function isVaultAvailable(ctx) { + if (!ctx.app) return false; + return ctx.app.platform === "windows"; } - function writeJson(ctx, path, value) { - try { - ctx.host.fs.writeText(path, JSON.stringify(value)); - } catch (e) { - ctx.host.log.warn("writeJson failed for " + path + ": " + String(e)); - } + function isWindows(ctx) { + if (!ctx.app) return false; + return ctx.app.platform === "windows"; } function saveToken(ctx, token) { + if (isVaultAvailable(ctx)) { + try { + ctx.host.vault.write(VAULT_KEY, JSON.stringify({ token: token })); + } catch (e) { + ctx.host.log.warn("vault write failed: " + String(e)); + } + return; + } if (isKeychainAvailable(ctx)) { try { ctx.host.keychain.writeGenericPassword( @@ -38,10 +38,17 @@ ctx.host.log.warn("keychain write failed: " + String(e)); } } - writeJson(ctx, ctx.app.pluginDataDir + "/auth.json", { token: token }); } function clearCachedToken(ctx) { + if (isVaultAvailable(ctx)) { + try { + ctx.host.vault.delete(VAULT_KEY); + } catch (e) { + ctx.host.log.info("vault delete failed: " + String(e)); + } + return; + } if (isKeychainAvailable(ctx)) { try { ctx.host.keychain.deleteGenericPassword(KEYCHAIN_SERVICE); @@ -49,7 +56,6 @@ ctx.host.log.info("keychain delete failed: " + String(e)); } } - writeJson(ctx, ctx.app.pluginDataDir + "/auth.json", null); } function loadTokenFromKeychain(ctx) { @@ -69,43 +75,98 @@ return null; } - function loadTokenFromGhCli(ctx) { - if (!isKeychainAvailable(ctx)) return null; + function loadTokenFromVault(ctx) { + if (!isVaultAvailable(ctx)) return null; try { - const raw = ctx.host.keychain.readGenericPassword(GH_KEYCHAIN_SERVICE); + const raw = ctx.host.vault.read(VAULT_KEY); if (raw) { - let token = raw; - if ( - typeof token === "string" && - token.indexOf("go-keyring-base64:") === 0 - ) { - token = ctx.base64.decode(token.slice("go-keyring-base64:".length)); - } - if (token) { - ctx.host.log.info("token loaded from gh CLI keychain"); - return { token: token, source: "gh-cli" }; + const parsed = ctx.util.tryParseJson(raw); + if (parsed && parsed.token) { + ctx.host.log.info("token loaded from OpenUsage vault"); + return { token: parsed.token, source: "vault" }; } } } catch (e) { - ctx.host.log.info("gh CLI keychain read failed: " + String(e)); + ctx.host.log.info("OpenUsage vault read failed: " + String(e)); + } + return null; + } + + function loadTokenFromGhCli(ctx) { + if (isKeychainAvailable(ctx)) { + try { + const raw = ctx.host.keychain.readGenericPassword(GH_KEYCHAIN_SERVICE); + if (raw) { + let token = raw; + if ( + typeof token === "string" && + token.indexOf("go-keyring-base64:") === 0 + ) { + token = ctx.base64.decode(token.slice("go-keyring-base64:".length)); + } + if (token) { + ctx.host.log.info("token loaded from gh CLI keychain"); + return { token: token, source: "gh-cli" }; + } + } + } catch (e) { + ctx.host.log.info("gh CLI keychain read failed: " + String(e)); + } + return null; + } + + if (isWindows(ctx)) { + const token = loadTokenFromGhCliFile(ctx); + if (token) { + ctx.host.log.info("token loaded from gh CLI config file"); + return { token: token, source: "gh-cli" }; + } + } + + return null; + } + + function loadTokenFromGhCliFile(ctx) { + const paths = [ + "~/.config/gh/hosts.yml", + "~/AppData/Roaming/GitHub CLI/hosts.yml", + "~/AppData/Roaming/gh/hosts.yml", + ]; + for (const path of paths) { + try { + if (!ctx.host.fs.exists(path)) continue; + const text = ctx.host.fs.readText(path); + const token = parseGhHostsToken(text); + if (token) return token; + } catch (e) { + ctx.host.log.warn("gh hosts file read failed: " + String(e)); + } } return null; } - function loadTokenFromStateFile(ctx) { - const data = readJson(ctx, ctx.app.pluginDataDir + "/auth.json"); - if (data && data.token) { - ctx.host.log.info("token loaded from state file"); - return { token: data.token, source: "state" }; + function parseGhHostsToken(text) { + const lines = String(text || "").split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (trimmed.startsWith("oauth_token:")) { + let value = trimmed.slice("oauth_token:".length).trim(); + if (!value) return null; + if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + return value || null; + } } return null; } function loadToken(ctx) { return ( + loadTokenFromVault(ctx) || loadTokenFromKeychain(ctx) || - loadTokenFromGhCli(ctx) || - loadTokenFromStateFile(ctx) + loadTokenFromGhCli(ctx) ); } diff --git a/plugins/copilot/plugin.test.js b/plugins/copilot/plugin.test.js index 48a89989..94282ae0 100644 --- a/plugins/copilot/plugin.test.js +++ b/plugins/copilot/plugin.test.js @@ -42,9 +42,9 @@ function setGhCliKeychain(ctx, value) { }); } -function setStateFileToken(ctx, token) { - ctx.host.fs.writeText( - ctx.app.pluginDataDir + "/auth.json", +function setVaultToken(ctx, token) { + ctx.host.vault.write( + "copilot:token", JSON.stringify({ token }), ); } @@ -102,9 +102,10 @@ describe("copilot plugin", () => { expect(call.headers.Authorization).toBe("token gho_encoded_token"); }); - it("loads token from state file", async () => { + it("loads token from vault on Windows", async () => { const ctx = makePluginTestContext(); - setStateFileToken(ctx, "ghu_state"); + ctx.app.platform = "windows"; + setVaultToken(ctx, "ghu_state"); mockUsageOk(ctx); const plugin = await loadPlugin(); const result = plugin.probe(ctx); @@ -128,18 +129,19 @@ describe("copilot plugin", () => { expect(call.headers.Authorization).toBe("token ghu_keychain"); }); - it("prefers keychain over state file", async () => { + it("prefers vault on Windows", async () => { const ctx = makePluginTestContext(); + ctx.app.platform = "windows"; setKeychainToken(ctx, "ghu_keychain"); - setStateFileToken(ctx, "ghu_state"); + setVaultToken(ctx, "ghu_state"); mockUsageOk(ctx); const plugin = await loadPlugin(); plugin.probe(ctx); const call = ctx.host.http.request.mock.calls[0][0]; - expect(call.headers.Authorization).toBe("token ghu_keychain"); + expect(call.headers.Authorization).toBe("token ghu_state"); }); - it("persists token from gh-cli to keychain and state file", async () => { + it("persists token from gh-cli to keychain", async () => { const ctx = makePluginTestContext(); setGhCliKeychain(ctx, "gho_persist"); mockUsageOk(ctx); @@ -149,10 +151,7 @@ describe("copilot plugin", () => { "OpenUsage-copilot", JSON.stringify({ token: "gho_persist" }), ); - const stateFile = ctx.host.fs.readText( - ctx.app.pluginDataDir + "/auth.json", - ); - expect(JSON.parse(stateFile).token).toBe("gho_persist"); + expect(ctx.host.vault.write).not.toHaveBeenCalled(); }); it("does not persist token loaded from OpenUsage keychain", async () => { diff --git a/plugins/test-helpers.js b/plugins/test-helpers.js index cfddec6a..67443440 100644 --- a/plugins/test-helpers.js +++ b/plugins/test-helpers.js @@ -2,6 +2,7 @@ import { vi } from "vitest" export const makeCtx = () => { const files = new Map() + const vaultStore = new Map() const ctx = { nowIso: "2026-02-02T00:00:00.000Z", @@ -25,6 +26,11 @@ export const makeCtx = () => { writeGenericPassword: vi.fn(), deleteGenericPassword: vi.fn(), }, + vault: { + read: vi.fn((name) => (vaultStore.has(name) ? vaultStore.get(name) : null)), + write: vi.fn((name, value) => vaultStore.set(name, value)), + delete: vi.fn((name) => vaultStore.delete(name)), + }, sqlite: { query: vi.fn(() => "[]"), exec: vi.fn(), diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 47379cab..9d15feb1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2976,6 +2976,7 @@ dependencies = [ "time", "tokio", "uuid", + "windows-dpapi", ] [[package]] @@ -5985,6 +5986,17 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-dpapi" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162a325089267c13a318d5b0356c785e0f548ca5a5584e1b6b7b49ecd163121a" +dependencies = [ + "anyhow", + "log", + "winapi", +] + [[package]] name = "windows-future" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b817026f..6b5f6030 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,6 +39,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } regex-lite = "0.1.9" rusqlite = { version = "0.33", features = ["bundled"] } +[target.'cfg(target_os = "windows")'.dependencies] +windows-dpapi = "0.1.0" + [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } objc2 = "0.6" diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 4994dfa4..1d8d228f 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -5,6 +5,9 @@ use rusqlite::{Connection, OpenFlags, Row}; use serde_json::{Number, Value}; use std::path::PathBuf; +#[cfg(target_os = "windows")] +use windows_dpapi::{decrypt_data, encrypt_data, Scope}; + const WHITELISTED_ENV_VARS: [&str; 1] = ["CODEX_HOME"]; /// Redact sensitive value to first4...last4 format (UTF-8 safe) @@ -187,6 +190,7 @@ pub fn inject_host_api<'js>( inject_env(ctx, &host)?; inject_http(ctx, &host, plugin_id)?; inject_keychain(ctx, &host)?; + inject_vault(ctx, &host, app_data_dir)?; inject_sqlite(ctx, &host)?; inject_ls(ctx, &host, plugin_id)?; @@ -1250,6 +1254,49 @@ fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result< Ok(()) } +fn inject_vault<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + app_data_dir: &PathBuf, +) -> rquickjs::Result<()> { + let vault_obj = Object::new(ctx.clone())?; + let base_dir = app_data_dir.clone(); + vault_obj.set( + "read", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, name: String| -> rquickjs::Result> { + vault_read(&ctx_inner, &base_dir, &name) + }, + )?, + )?; + + let base_dir = app_data_dir.clone(); + vault_obj.set( + "write", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, name: String, value: String| -> rquickjs::Result<()> { + vault_write(&ctx_inner, &base_dir, &name, &value) + }, + )?, + )?; + + let base_dir = app_data_dir.clone(); + vault_obj.set( + "delete", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, name: String| -> rquickjs::Result<()> { + vault_delete(&ctx_inner, &base_dir, &name) + }, + )?, + )?; + + host.set("vault", vault_obj)?; + Ok(()) +} + fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { let sqlite_obj = Object::new(ctx.clone())?; @@ -1336,6 +1383,94 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() Ok(()) } +fn vault_read( + ctx_inner: &Ctx<'_>, + app_data_dir: &PathBuf, + name: &str, +) -> rquickjs::Result> { + let path = vault_entry_path(ctx_inner, app_data_dir, name)?; + if !path.exists() { + return Ok(None); + } + let raw = std::fs::read_to_string(&path) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault read failed: {}", e)))?; + let encrypted = base64::engine::general_purpose::STANDARD + .decode(raw.trim().as_bytes()) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault decode failed: {}", e)))?; + let decrypted = vault_decrypt(&encrypted).map_err(|e| { + Exception::throw_message(ctx_inner, &format!("vault decrypt failed: {}", e)) + })?; + let value = String::from_utf8(decrypted) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault utf8 failed: {}", e)))?; + Ok(Some(value)) +} + +fn vault_write( + ctx_inner: &Ctx<'_>, + app_data_dir: &PathBuf, + name: &str, + value: &str, +) -> rquickjs::Result<()> { + let path = vault_entry_path(ctx_inner, app_data_dir, name)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + Exception::throw_message(ctx_inner, &format!("vault dir failed: {}", e)) + })?; + } + let encrypted = vault_encrypt(value.as_bytes()).map_err(|e| { + Exception::throw_message(ctx_inner, &format!("vault encrypt failed: {}", e)) + })?; + let encoded = base64::engine::general_purpose::STANDARD.encode(encrypted); + std::fs::write(&path, encoded) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault write failed: {}", e)))?; + Ok(()) +} + +fn vault_delete(ctx_inner: &Ctx<'_>, app_data_dir: &PathBuf, name: &str) -> rquickjs::Result<()> { + let path = vault_entry_path(ctx_inner, app_data_dir, name)?; + if !path.exists() { + return Ok(()); + } + std::fs::remove_file(&path) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault delete failed: {}", e)))?; + Ok(()) +} + +fn vault_entry_path( + ctx_inner: &Ctx<'_>, + app_data_dir: &PathBuf, + name: &str, +) -> rquickjs::Result { + if name.trim().is_empty() { + return Err(Exception::throw_message( + ctx_inner, + "vault name cannot be empty", + )); + } + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(name.as_bytes()); + Ok(app_data_dir.join("vault").join(encoded)) +} + +#[cfg(target_os = "windows")] +fn vault_encrypt(data: &[u8]) -> Result, String> { + encrypt_data(data, Scope::User).map_err(|e| e.to_string()) +} + +#[cfg(target_os = "windows")] +fn vault_decrypt(data: &[u8]) -> Result, String> { + decrypt_data(data, Scope::User).map_err(|e| e.to_string()) +} + +#[cfg(not(target_os = "windows"))] +fn vault_encrypt(_data: &[u8]) -> Result, String> { + Err("vault API is only supported on Windows".to_string()) +} + +#[cfg(not(target_os = "windows"))] +fn vault_decrypt(_data: &[u8]) -> Result, String> { + Err("vault API is only supported on Windows".to_string()) +} + fn sqlite_row_to_json(row: &Row<'_>) -> rusqlite::Result { let mut obj = serde_json::Map::new(); let stmt = row.as_ref(); From 672a781984c0e2fdb5da6b97041a3a0918ef5334 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Tue, 10 Feb 2026 18:26:53 -0500 Subject: [PATCH 09/16] ci: enforce Windows signing secrets --- .github/workflows/publish.yml | 18 ++++++++++++++- docs/breadcrumbs.md | 1 + docs/choices.md | 6 +++++ .../2026-02-10-windows-signing-guardrail.md | 23 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 docs/specs/2026-02-10-windows-signing-guardrail.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 916101eb..bcc59816 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -110,8 +110,24 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain rm certificate.p12 + - name: Validate Windows signing secrets + if: matrix.platform == 'windows-latest' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE)) { + Write-Error "Missing WINDOWS_CERTIFICATE secret for Windows release signing." + exit 1 + } + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD)) { + Write-Error "Missing WINDOWS_CERTIFICATE_PASSWORD secret for Windows release signing." + exit 1 + } + - name: Import Windows signing certificate - if: matrix.platform == 'windows-latest' && secrets.WINDOWS_CERTIFICATE != '' + if: matrix.platform == 'windows-latest' shell: pwsh env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 0cb3bb9b..88a1cf5e 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -21,3 +21,4 @@ - Switched plugin host sqlite to embedded `rusqlite` with bundled SQLite for cross-platform availability. - Replaced Windows process discovery `wmic` with PowerShell CIM JSON parsing in `src-tauri/src/plugin_engine/host_api.rs`. - Added Windows DPAPI-backed `host.vault` and updated Copilot auth to use it. +- Enforced Windows signing secrets in publish workflow to prevent unsigned releases. diff --git a/docs/choices.md b/docs/choices.md index 08f225b8..01b7c2ae 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -130,3 +130,9 @@ This document records opinionated defaults chosen during development. **Technical details:** - Encrypted bytes are stored as base64 under `appDataDir/vault/`. - `host.vault` throws on non-Windows platforms. + +### Fail Release If Windows Signing Secrets Missing + +**Context:** Release workflow allowed unsigned Windows artifacts when signing secrets were absent. + +**Decision:** Fail the Windows publish job if `WINDOWS_CERTIFICATE` or `WINDOWS_CERTIFICATE_PASSWORD` is missing. diff --git a/docs/specs/2026-02-10-windows-signing-guardrail.md b/docs/specs/2026-02-10-windows-signing-guardrail.md new file mode 100644 index 00000000..128121e6 --- /dev/null +++ b/docs/specs/2026-02-10-windows-signing-guardrail.md @@ -0,0 +1,23 @@ +# Windows Signing Guardrail + +## Goal +- Fail releases when Windows signing secrets are missing. + +## Scope +- Add a validation step in `.github/workflows/publish.yml` for Windows signing secrets. + +## Non-Goals +- Change signing identity, certificate format, or release process. + +## Approach +- On `windows-latest`, error out if `WINDOWS_CERTIFICATE` or `WINDOWS_CERTIFICATE_PASSWORD` is unset. +- Always run the import step after validation. + +## Testing +- No automated tests; validate via workflow run. + +## Risks +- Manual releases without secrets will now fail (intended). + +## Open Questions +- None. From 828edf9855fbab69d522ae050b4e41cf2be9fc24 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Tue, 10 Feb 2026 18:48:24 -0500 Subject: [PATCH 10/16] fix: improve Windows tray icon contrast --- docs/breadcrumbs.md | 1 + docs/choices.md | 6 +++++ docs/specs/2026-02-10-tray-icon-contrast.md | 25 +++++++++++++++++++++ src/App.tsx | 4 ++++ src/lib/tray-bars-icon.ts | 24 +++++++++++--------- 5 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 docs/specs/2026-02-10-tray-icon-contrast.md diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 88a1cf5e..17645e57 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -22,3 +22,4 @@ - Replaced Windows process discovery `wmic` with PowerShell CIM JSON parsing in `src-tauri/src/plugin_engine/host_api.rs`. - Added Windows DPAPI-backed `host.vault` and updated Copilot auth to use it. - Enforced Windows signing secrets in publish workflow to prevent unsigned releases. +- Adjusted dynamic tray icon rendering to stay visible on dark Windows taskbars. diff --git a/docs/choices.md b/docs/choices.md index 01b7c2ae..af1ccc54 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -136,3 +136,9 @@ This document records opinionated defaults chosen during development. **Context:** Release workflow allowed unsigned Windows artifacts when signing secrets were absent. **Decision:** Fail the Windows publish job if `WINDOWS_CERTIFICATE` or `WINDOWS_CERTIFICATE_PASSWORD` is missing. + +### Improve Windows Tray Icon Contrast + +**Context:** Dynamic tray icons used black fills and became invisible on dark Windows taskbars. + +**Decision:** Render dynamic tray icons in light ink (`#f8f8f8`) on Windows. diff --git a/docs/specs/2026-02-10-tray-icon-contrast.md b/docs/specs/2026-02-10-tray-icon-contrast.md new file mode 100644 index 00000000..ddf55b32 --- /dev/null +++ b/docs/specs/2026-02-10-tray-icon-contrast.md @@ -0,0 +1,25 @@ +# Windows Tray Icon Contrast + +## Goal +- Keep dynamic tray icons visible on dark Windows taskbars. + +## Scope +- Add configurable ink color for dynamic tray icons. +- Use light ink on Windows. + +## Non-Goals +- Theme detection or automatic light/dark switching. +- Changes to static tray icon assets. + +## Approach +- Add `color` to tray icon rendering API. +- Use `#f8f8f8` on Windows and black elsewhere. + +## Testing +- Manual: verify dynamic tray icon remains visible on Windows dark taskbar. + +## Risks +- Light ink may be too bright on light taskbars (acceptable until theme detection is added). + +## Open Questions +- None. diff --git a/src/App.tsx b/src/App.tsx index a130e3b9..5c729a1d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -102,12 +102,14 @@ function App() { const displayModeRef = useRef(displayMode) const trayIconStyleRef = useRef(trayIconStyle) const trayShowPercentageRef = useRef(trayShowPercentage) + const isWindowsRef = useRef(isWindows) useEffect(() => { pluginsMetaRef.current = pluginsMeta }, [pluginsMeta]) useEffect(() => { pluginSettingsRef.current = pluginSettings }, [pluginSettings]) useEffect(() => { pluginStatesRef.current = pluginStates }, [pluginStates]) useEffect(() => { displayModeRef.current = displayMode }, [displayMode]) useEffect(() => { trayIconStyleRef.current = trayIconStyle }, [trayIconStyle]) useEffect(() => { trayShowPercentageRef.current = trayShowPercentage }, [trayShowPercentage]) + useEffect(() => { isWindowsRef.current = isWindows }, [isWindows]) // Fetch app version and detect platform on mount useEffect(() => { @@ -157,6 +159,7 @@ function App() { : undefined const dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1 const sizePx = getTrayIconSizePx(dpr) + const trayColor = isWindowsRef.current ? "#f8f8f8" : "#000000" try { const image = await renderTrayBarsIcon({ @@ -165,6 +168,7 @@ function App() { style, percentText, providerIconUrl, + color: trayColor, }) let tray = trayRef.current if (!tray) { diff --git a/src/lib/tray-bars-icon.ts b/src/lib/tray-bars-icon.ts index 547455c9..359b9608 100644 --- a/src/lib/tray-bars-icon.ts +++ b/src/lib/tray-bars-icon.ts @@ -183,8 +183,10 @@ export function makeTrayBarsSvg(args: { style?: TrayIconStyle percentText?: string providerIconUrl?: string + color?: string }): string { - const { bars, sizePx, style = "bars", percentText, providerIconUrl } = args + const { bars, sizePx, style = "bars", percentText, providerIconUrl, color } = args + const ink = typeof color === "string" && color.length > 0 ? color : "black" const barsForStyle = getBarsForStyle(style, bars) const n = Math.max(1, Math.min(4, barsForStyle.length || 1)) const text = normalizePercentText(style, percentText) @@ -228,7 +230,7 @@ export function makeTrayBarsSvg(args: { const radius = Math.max(1, Math.floor(chartSize / 2 - strokeW / 2) + 0.5) parts.push( - `` + `` ) const fraction = barsForStyle[0]?.fraction @@ -238,7 +240,7 @@ export function makeTrayBarsSvg(args: { const circumference = 2 * Math.PI * radius const dash = circumference * clamped parts.push( - `` + `` ) } } @@ -258,7 +260,7 @@ export function makeTrayBarsSvg(args: { const radius = Math.max(2, iconSize / 2 - 1.5) const strokeW = Math.max(1.5, Math.round(iconSize * 0.14)) parts.push( - `` + `` ) } } else if (style !== "textOnly") { @@ -269,7 +271,7 @@ export function makeTrayBarsSvg(args: { // Track parts.push( - `` + `` ) const fraction = bar?.fraction @@ -279,7 +281,7 @@ export function makeTrayBarsSvg(args: { const movingEdgeRadius = Math.max(0, Math.floor(rx * 0.35)) if (fillW >= trackW) { parts.push( - `` + `` ) } else { const fillPath = makeRoundedBarPath({ @@ -290,7 +292,7 @@ export function makeTrayBarsSvg(args: { leftRadius: rx, rightRadius: movingEdgeRadius, }) - parts.push(``) + parts.push(``) } } @@ -304,7 +306,7 @@ export function makeTrayBarsSvg(args: { leftRadius: Math.max(0, Math.floor(rx * 0.2)), rightRadius: rx, }) - parts.push(``) + parts.push(``) } } } @@ -312,7 +314,7 @@ export function makeTrayBarsSvg(args: { if (text) { parts.push( - `${escapeXmlText(text)}` + `${escapeXmlText(text)}` ) } @@ -359,8 +361,9 @@ export async function renderTrayBarsIcon(args: { style?: TrayIconStyle percentText?: string providerIconUrl?: string + color?: string }): Promise { - const { bars, sizePx, style = "bars", percentText, providerIconUrl } = args + const { bars, sizePx, style = "bars", percentText, providerIconUrl, color } = args const text = normalizePercentText(style, percentText) const svg = makeTrayBarsSvg({ bars, @@ -368,6 +371,7 @@ export async function renderTrayBarsIcon(args: { style, percentText: text, providerIconUrl, + color, }) const layout = getSvgLayout({ sizePx, From 14a2e6b0512ae05ff942bbe1c56bfc49cb10ca4e Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Tue, 10 Feb 2026 18:48:48 -0500 Subject: [PATCH 11/16] fix: restore Linux placeholders --- docs/breadcrumbs.md | 1 + docs/choices.md | 9 ++++++ docs/specs/2026-02-10-linux-placeholders.md | 31 +++++++++++++++++++++ plugins/antigravity/plugin.js | 8 +++--- plugins/antigravity/plugin.json | 2 +- plugins/claude/plugin.json | 2 +- plugins/codex/plugin.json | 2 +- plugins/copilot/plugin.js | 7 ++++- plugins/copilot/plugin.json | 2 +- plugins/cursor/plugin.json | 2 +- plugins/windsurf/plugin.js | 8 +++--- plugins/windsurf/plugin.json | 2 +- src-tauri/capabilities/default.json | 3 +- src-tauri/src/window_manager.rs | 14 ++++++++++ 14 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 docs/specs/2026-02-10-linux-placeholders.md diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 17645e57..9524893d 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -23,3 +23,4 @@ - Added Windows DPAPI-backed `host.vault` and updated Copilot auth to use it. - Enforced Windows signing secrets in publish workflow to prevent unsigned releases. - Adjusted dynamic tray icon rendering to stay visible on dark Windows taskbars. +- Restored Linux placeholders for tray positioning and plugin availability. diff --git a/docs/choices.md b/docs/choices.md index af1ccc54..3ffcf256 100644 --- a/docs/choices.md +++ b/docs/choices.md @@ -137,6 +137,15 @@ This document records opinionated defaults chosen during development. **Decision:** Fail the Windows publish job if `WINDOWS_CERTIFICATE` or `WINDOWS_CERTIFICATE_PASSWORD` is missing. +### Enable Linux Plugin Placeholders + +**Context:** OS gating disabled plugins on Linux and tray positioning lacked a Linux implementation. + +**Decision:** Re-enable Linux in plugin manifests and add a basic Linux tray positioning implementation. + +**Technical details:** +- LS discovery on Linux assumes `language_server_linux_x64` as the process name. + ### Improve Windows Tray Icon Contrast **Context:** Dynamic tray icons used black fills and became invisible on dark Windows taskbars. diff --git a/docs/specs/2026-02-10-linux-placeholders.md b/docs/specs/2026-02-10-linux-placeholders.md new file mode 100644 index 00000000..ae9bd053 --- /dev/null +++ b/docs/specs/2026-02-10-linux-placeholders.md @@ -0,0 +1,31 @@ +# Linux Placeholder Enablement + +## Goal +- Restore Linux builds and plugin availability after OS gating changes. + +## Scope +- Add Linux implementation for tray window positioning. +- Include Linux in supported OS lists for major plugins. +- Update language server discovery to handle Linux process names. +- Allow Copilot to read gh CLI hosts file on Linux. + +## Non-Goals +- Guarantee full Linux feature parity. +- Add Linux-specific UI or packaging work. + +## Approach +- Provide a Linux `position_window_at_tray` that positions the window using the tray icon location. +- Add `linux` to plugin `os` arrays for Claude, Codex, Cursor, Windsurf, Copilot, Antigravity. +- Treat the Linux LS process name as `language_server_linux_x64` (placeholder). +- Use `~/.config/gh/hosts.yml` for Copilot token on Linux. + +## Testing +- `cargo test` (if Linux runner available) +- `bunx vitest run plugins/copilot/plugin.test.js` + +## Risks +- LS process name may differ on Linux distributions. +- Some providers may still fail due to upstream app path differences. + +## Open Questions +- None. diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 6cf48a44..48e5c779 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -4,9 +4,9 @@ // --- LS discovery --- function discoverLs(ctx) { - var processName = ctx.app.platform === "windows" - ? "language_server_windows_x64.exe" - : "language_server_macos" + var processName = ctx.app.platform === "windows" + ? "language_server_windows_x64.exe" + : (ctx.app.platform === "linux" ? "language_server_linux_x64" : "language_server_macos") return ctx.host.ls.discover({ processName: processName, @@ -32,7 +32,7 @@ extensionVersion: "unknown", ide: "antigravity", ideVersion: "unknown", - os: ctx.app.platform === "windows" ? "windows" : "macos", + os: ctx.app.platform === "windows" ? "windows" : (ctx.app.platform === "linux" ? "linux" : "macos"), }, }, }), diff --git a/plugins/antigravity/plugin.json b/plugins/antigravity/plugin.json index dfe91e32..06e0193b 100644 --- a/plugins/antigravity/plugin.json +++ b/plugins/antigravity/plugin.json @@ -6,7 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#4285F4", - "os": ["macos", "windows"], + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Gemini 3 Pro", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Gemini 3 Flash", "scope": "overview" }, diff --git a/plugins/claude/plugin.json b/plugins/claude/plugin.json index c8455e74..f3805f28 100644 --- a/plugins/claude/plugin.json +++ b/plugins/claude/plugin.json @@ -6,7 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#DE7356", - "os": ["macos", "windows"], + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Weekly", "scope": "overview" }, diff --git a/plugins/codex/plugin.json b/plugins/codex/plugin.json index 9b753989..acbcddb4 100644 --- a/plugins/codex/plugin.json +++ b/plugins/codex/plugin.json @@ -6,7 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#74AA9C", - "os": ["macos", "windows"], + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Weekly", "scope": "overview" }, diff --git a/plugins/copilot/plugin.js b/plugins/copilot/plugin.js index 5eabba07..2450e6f4 100644 --- a/plugins/copilot/plugin.js +++ b/plugins/copilot/plugin.js @@ -19,6 +19,11 @@ return ctx.app.platform === "windows"; } + function isLinux(ctx) { + if (!ctx.app) return false; + return ctx.app.platform === "linux"; + } + function saveToken(ctx, token) { if (isVaultAvailable(ctx)) { try { @@ -115,7 +120,7 @@ return null; } - if (isWindows(ctx)) { + if (isWindows(ctx) || isLinux(ctx)) { const token = loadTokenFromGhCliFile(ctx); if (token) { ctx.host.log.info("token loaded from gh CLI config file"); diff --git a/plugins/copilot/plugin.json b/plugins/copilot/plugin.json index 30bc2380..12e54944 100644 --- a/plugins/copilot/plugin.json +++ b/plugins/copilot/plugin.json @@ -6,7 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#A855F7", - "os": ["macos", "windows"], + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Premium", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Chat", "scope": "overview", "primaryOrder": 2 }, diff --git a/plugins/cursor/plugin.json b/plugins/cursor/plugin.json index f89eb1c2..2c517c2c 100644 --- a/plugins/cursor/plugin.json +++ b/plugins/cursor/plugin.json @@ -6,7 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#000000", - "os": ["macos", "windows"], + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Credits", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Plan usage", "scope": "overview", "primaryOrder": 2 }, diff --git a/plugins/windsurf/plugin.js b/plugins/windsurf/plugin.js index dbdcf606..596a26b5 100644 --- a/plugins/windsurf/plugin.js +++ b/plugins/windsurf/plugin.js @@ -32,9 +32,9 @@ // --- LS discovery --- function discoverLs(ctx) { - var processName = ctx.app.platform === "windows" - ? "language_server_windows_x64.exe" - : "language_server_macos" + var processName = ctx.app.platform === "windows" + ? "language_server_windows_x64.exe" + : (ctx.app.platform === "linux" ? "language_server_linux_x64" : "language_server_macos") return ctx.host.ls.discover({ processName: processName, @@ -83,7 +83,7 @@ extensionVersion: "unknown", ide: "windsurf", ideVersion: "unknown", - os: ctx.app.platform === "windows" ? "windows" : "macos", + os: ctx.app.platform === "windows" ? "windows" : (ctx.app.platform === "linux" ? "linux" : "macos"), }, }, }), diff --git a/plugins/windsurf/plugin.json b/plugins/windsurf/plugin.json index 8d3cb997..3131c8a0 100644 --- a/plugins/windsurf/plugin.json +++ b/plugins/windsurf/plugin.json @@ -6,7 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#111111", - "os": ["macos", "windows"], + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Prompt credits", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Flex credits", "scope": "overview" } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a85e482e..006a3d76 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,7 +7,8 @@ "core:default", "core:tray:default", "core:image:default", -"core:window:allow-set-size", + "os:default", + "core:window:allow-set-size", "core:window:allow-set-position", "core:window:allow-outer-position", "core:window:allow-outer-size", diff --git a/src-tauri/src/window_manager.rs b/src-tauri/src/window_manager.rs index 2bed0b07..66e01cba 100644 --- a/src-tauri/src/window_manager.rs +++ b/src-tauri/src/window_manager.rs @@ -328,3 +328,17 @@ fn calculate_window_position( pub fn position_window_at_tray(app_handle: &AppHandle, icon_position: Position, icon_size: Size) { crate::panel::position_panel_at_tray_icon(app_handle, icon_position, icon_size); } + +/// Linux version positions the window near the tray icon +#[cfg(target_os = "linux")] +pub fn position_window_at_tray( + app_handle: &AppHandle, + icon_position: PhysicalPosition, + _icon_size: tauri::PhysicalSize, +) -> tauri::Result<()> { + let window = app_handle + .get_webview_window("main") + .ok_or(tauri::Error::WindowNotFound)?; + window.set_position(tauri::Position::Physical(icon_position))?; + Ok(()) +} From 12d1c859ac87a65bc7ac6c260dc63c3f7cd63356 Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Tue, 10 Feb 2026 18:57:07 -0500 Subject: [PATCH 12/16] fix: scope gh token parsing --- docs/breadcrumbs.md | 1 - plugins/copilot/plugin.js | 21 +++++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md index 9524893d..16d577b9 100644 --- a/docs/breadcrumbs.md +++ b/docs/breadcrumbs.md @@ -17,7 +17,6 @@ ## 2026-02-10 -- Verified sqlite access uses external `sqlite3` CLI in `src-tauri/src/plugin_engine/host_api.rs` and no bundled sqlite binary/resources in `src-tauri/tauri.conf.json`. - Switched plugin host sqlite to embedded `rusqlite` with bundled SQLite for cross-platform availability. - Replaced Windows process discovery `wmic` with PowerShell CIM JSON parsing in `src-tauri/src/plugin_engine/host_api.rs`. - Added Windows DPAPI-backed `host.vault` and updated Copilot auth to use it. diff --git a/plugins/copilot/plugin.js b/plugins/copilot/plugin.js index 2450e6f4..96bb0c55 100644 --- a/plugins/copilot/plugin.js +++ b/plugins/copilot/plugin.js @@ -141,7 +141,7 @@ try { if (!ctx.host.fs.exists(path)) continue; const text = ctx.host.fs.readText(path); - const token = parseGhHostsToken(text); + const token = parseGhHostsToken(text, "github.com"); if (token) return token; } catch (e) { ctx.host.log.warn("gh hosts file read failed: " + String(e)); @@ -150,12 +150,25 @@ return null; } - function parseGhHostsToken(text) { + function parseGhHostsToken(text, host) { const lines = String(text || "").split(/\r?\n/); + let currentHost = null; for (const line of lines) { - const trimmed = line.trim(); + const raw = String(line || ""); + const trimmed = raw.trim(); if (!trimmed || trimmed.startsWith("#")) continue; - if (trimmed.startsWith("oauth_token:")) { + + const isTopLevel = raw.length > 0 && raw[0] !== " " && raw[0] !== "\t"; + if (isTopLevel && trimmed.endsWith(":")) { + let key = trimmed.slice(0, -1).trim(); + if ((key.startsWith("\"") && key.endsWith("\"")) || (key.startsWith("'") && key.endsWith("'"))) { + key = key.slice(1, -1); + } + currentHost = key || null; + continue; + } + + if (currentHost === host && trimmed.startsWith("oauth_token:")) { let value = trimmed.slice("oauth_token:".length).trim(); if (!value) return null; if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { From 3e8e3b77e38b0b0703b0c91360d66c3d330e8d50 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Fri, 13 Feb 2026 16:04:23 +0400 Subject: [PATCH 13/16] feat(codex): surface GPT-5.3-Codex-Spark per-model rate limits (#176) * fix(analytics): reduce noisy event volume with dedupe guards Drop page_viewed tracking, dedupe provider_fetch_error for 60 minutes, and gate app_started to once per version/day so analytics stays meaningful within quota limits. Co-authored-by: Cursor * feat(codex): surface GPT-5.3-Codex-Spark per-model rate limits The Codex usage API now returns `additional_rate_limits` with per-model session/weekly windows. Parse the array generically so future models auto-appear as detail-scoped progress lines. Co-authored-by: Cursor * fix(codex): guard limit_name to string before .replace() Handles malformed API entries where limit_name could be a non-string value (number, object) that would crash on .replace(). Co-authored-by: Cursor --------- Co-authored-by: Cursor --- plugins/codex/plugin.js | 34 +++++++++ plugins/codex/plugin.json | 2 + plugins/codex/plugin.test.js | 135 +++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+) diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index efe201a4..03de7b31 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -411,6 +411,40 @@ } } + if (Array.isArray(data.additional_rate_limits)) { + for (const entry of data.additional_rate_limits) { + if (!entry || !entry.rate_limit) continue + var name = typeof entry.limit_name === "string" ? entry.limit_name : "" + var shortName = name.replace(/^GPT-[\d.]+-Codex-/, "") + if (!shortName) shortName = name || "Model" + var rl = entry.rate_limit + if (rl.primary_window && typeof rl.primary_window.used_percent === "number") { + lines.push(ctx.line.progress({ + label: shortName, + used: rl.primary_window.used_percent, + limit: 100, + format: { kind: "percent" }, + resetsAt: getResetsAtIso(ctx, nowSec, rl.primary_window), + periodDurationMs: typeof rl.primary_window.limit_window_seconds === "number" + ? rl.primary_window.limit_window_seconds * 1000 + : PERIOD_SESSION_MS + })) + } + if (rl.secondary_window && typeof rl.secondary_window.used_percent === "number") { + lines.push(ctx.line.progress({ + label: shortName + " Weekly", + used: rl.secondary_window.used_percent, + limit: 100, + format: { kind: "percent" }, + resetsAt: getResetsAtIso(ctx, nowSec, rl.secondary_window), + periodDurationMs: typeof rl.secondary_window.limit_window_seconds === "number" + ? rl.secondary_window.limit_window_seconds * 1000 + : PERIOD_WEEKLY_MS + })) + } + } + } + if (reviewWindow) { const used = reviewWindow.used_percent if (typeof used === "number") { diff --git a/plugins/codex/plugin.json b/plugins/codex/plugin.json index 6cd7d93f..a235144e 100644 --- a/plugins/codex/plugin.json +++ b/plugins/codex/plugin.json @@ -13,6 +13,8 @@ "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Weekly", "scope": "overview" }, + { "type": "progress", "label": "Spark", "scope": "detail" }, + { "type": "progress", "label": "Spark Weekly", "scope": "detail" }, { "type": "progress", "label": "Reviews", "scope": "detail" }, { "type": "progress", "label": "Credits", "scope": "detail" } ] diff --git a/plugins/codex/plugin.test.js b/plugins/codex/plugin.test.js index 3055bb80..b87290e2 100644 --- a/plugins/codex/plugin.test.js +++ b/plugins/codex/plugin.test.js @@ -374,6 +374,141 @@ describe("codex plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Usage request failed after refresh") }) + it("surfaces additional_rate_limits as Spark lines", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "token" }, + last_refresh: new Date().toISOString(), + })) + const now = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now) + const nowSec = Math.floor(now / 1000) + + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 5, reset_after_seconds: 60 }, + secondary_window: { used_percent: 10, reset_after_seconds: 120 }, + }, + additional_rate_limits: [ + { + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { + primary_window: { + used_percent: 25, + limit_window_seconds: 18000, + reset_after_seconds: 3600, + reset_at: nowSec + 3600, + }, + secondary_window: { + used_percent: 40, + limit_window_seconds: 604800, + reset_after_seconds: 86400, + reset_at: nowSec + 86400, + }, + }, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const spark = result.lines.find((l) => l.label === "Spark") + expect(spark).toBeTruthy() + expect(spark.used).toBe(25) + expect(spark.limit).toBe(100) + expect(spark.periodDurationMs).toBe(18000000) + expect(spark.resetsAt).toBe(new Date((nowSec + 3600) * 1000).toISOString()) + + const sparkWeekly = result.lines.find((l) => l.label === "Spark Weekly") + expect(sparkWeekly).toBeTruthy() + expect(sparkWeekly.used).toBe(40) + expect(sparkWeekly.limit).toBe(100) + expect(sparkWeekly.periodDurationMs).toBe(604800000) + expect(sparkWeekly.resetsAt).toBe(new Date((nowSec + 86400) * 1000).toISOString()) + + nowSpy.mockRestore() + }) + + it("handles additional_rate_limits with missing fields and fallback labels", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "token" }, + last_refresh: new Date().toISOString(), + })) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + additional_rate_limits: [ + // Entry with no limit_name, no limit_window_seconds, no secondary + { + limit_name: "", + rate_limit: { + primary_window: { used_percent: 10, reset_after_seconds: 60 }, + secondary_window: null, + }, + }, + // Malformed entry (no rate_limit) + { limit_name: "Bad" }, + // Null entry + null, + ], + }), + }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const modelLine = result.lines.find((l) => l.label === "Model") + expect(modelLine).toBeTruthy() + expect(modelLine.used).toBe(10) + expect(modelLine.periodDurationMs).toBe(5 * 60 * 60 * 1000) // fallback PERIOD_SESSION_MS + // No weekly line for this entry since secondary_window is null + expect(result.lines.find((l) => l.label === "Model Weekly")).toBeUndefined() + // Malformed and null entries should be skipped + expect(result.lines.find((l) => l.label === "Bad")).toBeUndefined() + }) + + it("handles missing or empty additional_rate_limits gracefully", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "token" }, + last_refresh: new Date().toISOString(), + })) + + // Missing field + ctx.host.http.request.mockReturnValueOnce({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 5, reset_after_seconds: 60 }, + }, + }), + }) + const plugin = await loadPlugin() + const result1 = plugin.probe(ctx) + expect(result1.lines.find((l) => l.label === "Spark")).toBeUndefined() + + // Empty array + ctx.host.http.request.mockReturnValueOnce({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 5, reset_after_seconds: 60 }, + }, + additional_rate_limits: [], + }), + }) + const result2 = plugin.probe(ctx) + expect(result2.lines.find((l) => l.label === "Spark")).toBeUndefined() + }) + it("throws token expired when refresh retry is unauthorized", async () => { const ctx = makeCtx() ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ From 6ca4794f812a18a4b18440a8240deddc3cd3abba Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Fri, 13 Feb 2026 16:04:35 +0400 Subject: [PATCH 14/16] fix(codex): replace var with const/let in rate-limit loop Co-authored-by: Cursor --- plugins/codex/plugin.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index 03de7b31..2c357823 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -414,10 +414,10 @@ if (Array.isArray(data.additional_rate_limits)) { for (const entry of data.additional_rate_limits) { if (!entry || !entry.rate_limit) continue - var name = typeof entry.limit_name === "string" ? entry.limit_name : "" - var shortName = name.replace(/^GPT-[\d.]+-Codex-/, "") + const name = typeof entry.limit_name === "string" ? entry.limit_name : "" + let shortName = name.replace(/^GPT-[\d.]+-Codex-/, "") if (!shortName) shortName = name || "Model" - var rl = entry.rate_limit + const rl = entry.rate_limit if (rl.primary_window && typeof rl.primary_window.used_percent === "number") { lines.push(ctx.line.progress({ label: shortName, From abe3f24aa9ab55dc4f30d3dabac5e53a5ca5fa88 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Fri, 13 Feb 2026 16:05:06 +0400 Subject: [PATCH 15/16] chore: bump version to 0.6.3 Co-authored-by: Cursor --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b9e74e13..b16807d4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openusage", "private": true, - "version": "0.6.2", + "version": "0.6.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 19ceaf0d..9ee9ca8e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2951,7 +2951,7 @@ dependencies = [ [[package]] name = "openusage" -version = "0.6.2" +version = "0.6.3" dependencies = [ "base64 0.22.1", "dirs 6.0.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4501723f..ff5796db 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openusage" -version = "0.6.2" +version = "0.6.3" description = "OpenUsage is an open source AI subscription limit tracker" authors = ["Robin Ebers"] edition = "2024" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 39cb258e..bf200b00 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenUsage", - "version": "0.6.2", + "version": "0.6.3", "identifier": "com.sunstory.openusage", "build": { "beforeDevCommand": "bun run bundle:plugins && bun run dev", From ae4445b42f38186ad5f92f10713a1b790e93be5f Mon Sep 17 00:00:00 2001 From: Noisemaker111 Date: Fri, 13 Feb 2026 13:12:44 -0500 Subject: [PATCH 16/16] feat: add Windows build support to release workflow - Add Windows certificate env vars to tauri-action step - Create OWNER_GUIDE.md for Windows certificate setup --- .github/workflows/publish.yml | 3 + OWNER_FOLLOW.md | 11 --- OWNER_GUIDE.md | 134 ++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 11 deletions(-) delete mode 100644 OWNER_FOLLOW.md create mode 100644 OWNER_GUIDE.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bcc59816..f9a13252 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -156,6 +156,9 @@ jobs: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} with: tagName: ${{ env.RELEASE_TAG }} releaseName: ${{ env.RELEASE_TAG }} diff --git a/OWNER_FOLLOW.md b/OWNER_FOLLOW.md deleted file mode 100644 index 64f40ede..00000000 --- a/OWNER_FOLLOW.md +++ /dev/null @@ -1,11 +0,0 @@ -# Owner follow-up - -## Windows code signing -- Add GitHub secrets: - - `WINDOWS_CERTIFICATE`: base64-encoded PFX - - `WINDOWS_CERTIFICATE_PASSWORD`: PFX password -- Update `src-tauri/tauri.conf.json` with Windows signing config: - - `bundle.windows.certificateThumbprint` - - `bundle.windows.digestAlgorithm`: `sha256` - - `bundle.windows.timestampUrl`: trusted timestamp URL -- After secrets + config are set, Windows updater artifacts will be signed during publish. diff --git a/OWNER_GUIDE.md b/OWNER_GUIDE.md new file mode 100644 index 00000000..789b63e1 --- /dev/null +++ b/OWNER_GUIDE.md @@ -0,0 +1,134 @@ +# Owner Guide: Windows Release Setup + +Generate Windows certificate from your Mac, add GitHub secrets, push a tag. + +## 1. Generate Windows Certificate + +Install OpenSSL if not present: + +```bash +brew install openssl +``` + +Create certificate: + +```bash +# Create private key +openssl genrsa -out windows.key 4096 + +# Create certificate +openssl req -new -x509 -key windows.key -out windows.crt -days 365 \ + -subj "/CN=Sunstory/O=Sunstory/C=US" + +# Convert to PFX +openssl pkcs12 -export -out codesign.pfx -inkey windows.key -in windows.crt +# Enter password when prompted (save this!) +``` + +For production: Buy a certificate from DigiCert/Sectigo instead. + +## 2. Encode for GitHub Secrets + +```bash +# Base64 encode +cat codesign.pfx | base64 > cert_base64.txt + +# Copy to clipboard (macOS) +cat cert_base64.txt | pbcopy + +# Cleanup sensitive files +rm windows.key windows.crt codesign.pfx +``` + +## 3. Add GitHub Secrets + +Go to: `https://github.com/robinebers/openusage/settings/secrets/actions` + +Add: + +| Secret | Value | +|--------|-------| +| `WINDOWS_CERTIFICATE` | Paste from clipboard (base64) | +| `WINDOWS_CERTIFICATE_PASSWORD` | Password from step 1 | + +## 4. Update Versions + +Set same version in all 3 files: + +```bash +# Check current +grep '"version"' package.json src-tauri/tauri.conf.json +grep '^version' src-tauri/Cargo.toml + +# Edit files (use your editor) +cursor src-tauri/tauri.conf.json # "version": "0.6.4" +cursor src-tauri/Cargo.toml # version = "0.6.4" +cursor package.json # "version": "0.6.4" +``` + +## 5. Create Release + +```bash +# Commit +git add src-tauri/tauri.conf.json src-tauri/Cargo.toml package.json +git commit -m "chore: release v0.6.4" + +# Push +git push origin main + +# Tag +git tag v0.6.4 +git push origin v0.6.4 +``` + +## 6. Monitor Build + +```bash +# Watch workflow +gh run watch + +# Or open browser +open "https://github.com/robinebers/openusage/actions" +``` + +## 7. Verify + +```bash +# Check release assets +gh release view v0.6.4 +``` + +Should have: +- `OpenUsage_0.6.4_aarch64.dmg` +- `OpenUsage_0.6.4_x64.dmg` +- `OpenUsage_0.6.4_x64_en-US.msi` (Windows) +- `OpenUsage_0.6.4_x64-setup.exe` (Windows) +- `latest.json` + +## Troubleshooting + +**Missing WINDOWS_CERTIFICATE** +- Check secret exists in GitHub → Settings → Secrets + +**Version mismatch** +- All 3 files must have same version number + +**No Windows assets** +- Check workflow logs for Windows build errors +- Verify `WINDOWS_CERTIFICATE_PASSWORD` is correct + +## Commands Reference + +```bash +# Check versions +grep -h version package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml + +# List releases +gh release list + +# View workflow runs +gh run list --workflow=publish.yml + +# Check specific run +gh run view +```