diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c15fd3dd..7ec45260 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,10 +15,8 @@ jobs: fail-fast: false matrix: include: - - platform: macos-latest - 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_name }} @@ -29,7 +27,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin,x86_64-apple-darwin + targets: aarch64-apple-darwin,x86_64-apple-darwin,x86_64-pc-windows-msvc - uses: swatinem/rust-cache@v2 with: workspaces: "./src-tauri -> target" @@ -41,6 +39,7 @@ 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 [[ "$COUNT" -lt 1 ]]; then @@ -49,6 +48,7 @@ jobs: fi - name: Validate release tag + shell: bash run: | if [[ -z "$RELEASE_TAG" ]]; then echo "Missing RELEASE_TAG (push a v* tag)." @@ -60,6 +60,7 @@ jobs: fi - name: Validate app version matches tag + shell: bash run: | TAG_VERSION="${RELEASE_TAG#v}" @@ -81,11 +82,16 @@ 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 }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | + if [[ -z "$APPLE_CERTIFICATE" ]]; then + echo "Skipping Apple Certificate import because secret is empty." + exit 0 + fi echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain @@ -95,6 +101,24 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain rm certificate.p12 + - name: Handle missing updater key (for forks) + shell: bash + env: + TAURI_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + if [[ -z "$TAURI_KEY" ]]; then + echo "HAS_TAURI_KEY=false" >> $GITHUB_ENV + node -e " + const fs = require('fs'); + const conf = JSON.parse(fs.readFileSync('./src-tauri/tauri.conf.json')); + if(conf.bundle) conf.bundle.createUpdaterArtifacts = false; + if(conf.plugins && conf.plugins.updater) delete conf.plugins.updater; + fs.writeFileSync('./src-tauri/tauri.conf.json', JSON.stringify(conf, null, 2)); + " + else + echo "HAS_TAURI_KEY=true" >> $GITHUB_ENV + fi + - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -114,10 +138,12 @@ jobs: releaseName: ${{ env.RELEASE_TAG }} releaseDraft: false prerelease: false - includeUpdaterJson: true + includeUpdaterJson: ${{ env.HAS_TAURI_KEY == 'true' }} args: ${{ matrix.args }} - name: Verify updater assets uploaded + if: env.HAS_TAURI_KEY == 'true' + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f10467f..a8787fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.6.37 + +### Bug Fixes +- fix: cursor db path on windows and os-agnostic error messages by @Native Muttaqien + +--- + +### Changelog + +**Full Changelog**: [v0.6.36...v0.6.37](https://github.com/samlehoy/openusage/compare/v0.6.36...v0.6.37) + +- [3f5ee17](https://github.com/samlehoy/openusage/commit/3f5ee17) fix: cursor db path on windows and os-agnostic error messages by @Native Muttaqien + ## v0.6.24 ### New Features diff --git a/package.json b/package.json index b153240f..ed9f4e7e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openusage", "private": true, - "version": "0.6.24", + "version": "0.6.39", "type": "module", "scripts": { "dev": "vite", diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 2b8d515b..a288d920 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -1,6 +1,7 @@ (function () { var LS_SERVICE = "exa.language_server_pb.LanguageServerService" - var STATE_DB = "~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb" + var STATE_DB_MAC = "~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb" + var STATE_DB_WIN = "~/AppData/Roaming/Antigravity/User/globalStorage/state.vscdb" var CLOUD_CODE_URLS = [ "https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com", @@ -94,8 +95,9 @@ function loadOAuthTokens(ctx) { try { + var dbPath = ctx.app.platform === "windows" ? STATE_DB_WIN : STATE_DB_MAC var rows = ctx.host.sqlite.query( - STATE_DB, + dbPath, "SELECT value FROM ItemTable WHERE key = '" + OAUTH_TOKEN_KEY + "' LIMIT 1" ) var parsed = ctx.util.tryParseJson(rows) @@ -187,8 +189,9 @@ // --- LS discovery --- function discoverLs(ctx) { + var procName = ctx.app.platform === "windows" ? "language_server.exe" : "language_server_macos" return ctx.host.ls.discover({ - processName: "language_server_macos", + processName: procName, markers: ["antigravity"], csrfFlag: "--csrf_token", portFlag: "--extension_server_port", diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index 0cdb24fa..1aa6af09 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -425,9 +425,9 @@ if (body) errorCode = body.error || body.error_description ctx.host.log.error("refresh failed: status=" + resp.status + " error=" + String(errorCode)) if (errorCode === "invalid_grant") { - throw "Session expired. Run `claude` to log in again." + throw "Session expired. Please log in again." } - throw "Token expired. Run `claude` to log in again." + throw "Token expired. Please log in again." } if (resp.status < 200 || resp.status >= 300) { ctx.host.log.warn("refresh returned unexpected status: " + resp.status) @@ -632,7 +632,7 @@ const creds = loadCredentials(ctx) if (!creds || !creds.oauth || !creds.oauth.accessToken || !creds.oauth.accessToken.trim()) { ctx.host.log.error("probe failed: not logged in") - throw "Not logged in. Run `claude` to authenticate." + throw "Not logged in. Please authenticate with Claude Code." } const nowMs = Date.now() @@ -709,7 +709,7 @@ if (ctx.util.isAuthStatus(resp.status)) { ctx.host.log.error("usage returned auth error after all retries: status=" + resp.status) - throw "Token expired. Run `claude` to log in again." + throw "Token expired. Please log in again." } if (resp.status === 429) { diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 4229919d..53ac41fe 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -1,6 +1,7 @@ (function () { - const STATE_DB = - "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + const STATE_DB = ctx.app.platform === "windows" + ? "~/AppData/Roaming/Cursor/User/globalStorage/state.vscdb" + : "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" const KEYCHAIN_ACCESS_TOKEN_SERVICE = "cursor-access-token" const KEYCHAIN_REFRESH_TOKEN_SERVICE = "cursor-refresh-token" const BASE_URL = "https://api2.cursor.sh" @@ -12,7 +13,7 @@ const STRIPE_URL = "https://cursor.com/api/auth/stripe" const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration - const LOGIN_HINT = "Sign in via Cursor app or run `agent login`." + const LOGIN_HINT = "Sign in via Cursor app." function readStateValue(ctx, key) { try { diff --git a/plugins/windsurf/plugin.js b/plugins/windsurf/plugin.js index eb167b60..51842fc6 100644 --- a/plugins/windsurf/plugin.js +++ b/plugins/windsurf/plugin.js @@ -11,12 +11,14 @@ { marker: "windsurf", ideName: "windsurf", - stateDb: "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb", + stateDbWin: "~/AppData/Roaming/Windsurf/User/globalStorage/state.vscdb", + stateDbMac: "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb", }, { marker: "windsurf-next", ideName: "windsurf-next", - stateDb: "~/Library/Application Support/Windsurf - Next/User/globalStorage/state.vscdb", + stateDbWin: "~/AppData/Roaming/Windsurf - Next/User/globalStorage/state.vscdb", + stateDbMac: "~/Library/Application Support/Windsurf - Next/User/globalStorage/state.vscdb", }, ] @@ -38,8 +40,9 @@ function loadApiKey(ctx, variant) { try { + var dbPath = ctx.app.platform === "windows" ? variant.stateDbWin : variant.stateDbMac var rows = ctx.host.sqlite.query( - variant.stateDb, + dbPath, "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus' LIMIT 1" ) var parsed = ctx.util.tryParseJson(rows) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 902e6516..d3b4f9e6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openusage" -version = "0.6.24" +version = "0.6.39" description = "OpenUsage is an open source AI subscription limit tracker" authors = ["Robin Ebers"] edition = "2024" @@ -21,8 +21,9 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } -serde_json = "1" -tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } +serde_json = "1.0.133" +rusqlite = { version = "0.31.0", features = ["bundled"] } + time = { version = "0.3.47", features = ["formatting"] } dirs = "6" log = "0.4" @@ -44,6 +45,7 @@ aes-gcm = "0.10.3" sha2 = "0.11" [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-app-kit = { version = "0.3", features = ["NSEvent", "NSScreen", "NSGraphics"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cc6aa864..30eeb6d2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,11 +11,19 @@ "core:window:allow-outer-size", "core:window:allow-inner-size", "core:window:allow-scale-factor", + "core:window:allow-start-dragging", + "core:window:allow-set-position", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:window:allow-is-visible", + "core:window:allow-set-resizable", "opener:default", "store:default", "aptabase:allow-track-event", "updater:default", "process:allow-restart", + "process:allow-exit", "global-shortcut:default", "autostart:default", "core:menu:default" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 702aa988..f196bda1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,14 +5,17 @@ mod local_http_api; mod panel; mod plugin_engine; mod tray; +pub mod utils; #[cfg(target_os = "macos")] mod webkit_config; use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; +pub static IS_PINNED: AtomicBool = AtomicBool::new(false); + use serde::Serialize; use tauri::Emitter; use tauri_plugin_aptabase::EventTracker; @@ -200,10 +203,7 @@ fn init_panel(app_handle: tauri::AppHandle) { #[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(); - } + panel::hide_panel(&app_handle); } #[tauri::command] @@ -217,6 +217,29 @@ fn open_devtools(#[allow(unused)] app_handle: tauri::AppHandle) { } } +#[tauri::command] +fn reposition_panel(app_handle: tauri::AppHandle) { + use tauri::Manager; + if crate::IS_PINNED.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + if let Some(tray) = app_handle.tray_by_id("tray") { + if let Ok(Some(rect)) = tray.rect() { + panel::position_panel_at_tray_icon(&app_handle, rect.position, rect.size); + } + } +} + +#[tauri::command] +fn set_pinned(pinned: bool) { + crate::IS_PINNED.store(pinned, std::sync::atomic::Ordering::SeqCst); +} + +#[tauri::command] +fn is_pinned() -> bool { + crate::IS_PINNED.load(std::sync::atomic::Ordering::SeqCst) +} + #[tauri::command] async fn start_probe_batch( app_handle: tauri::AppHandle, @@ -504,11 +527,18 @@ pub fn run() { let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let _guard = runtime.enter(); - tauri::Builder::default() + #[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_store::Builder::default().build()); + + #[cfg(target_os = "macos")] + { + builder = builder.plugin(tauri_nspanel::init()); + } + + builder .plugin( tauri_plugin_log::Builder::new() .targets([ @@ -533,7 +563,10 @@ pub fn run() { start_probe_batch, list_plugins, get_log_path, - update_global_shortcut + update_global_shortcut, + reposition_panel, + set_pinned, + is_pinned ]) .setup(|app| { #[cfg(target_os = "macos")] @@ -583,10 +616,19 @@ pub fn run() { local_http_api::init(&app_data_dir, known_plugin_ids); local_http_api::start_server(); - tray::create(app.handle())?; + log::info!("Setup: Starting tray creation"); + if let Err(e) = tray::create(app.handle()) { + log::error!("Setup: Tray creation failed: {:?}", e); + } + log::info!("Setup: Starting panel init"); + if let Err(e) = panel::init(app.handle()) { + log::error!("Setup: Panel init failed: {:?}", e); + } + log::info!("Setup: Finished"); - app.handle() - .plugin(tauri_plugin_updater::Builder::new().build())?; + if let Err(e) = app.handle().plugin(tauri_plugin_updater::Builder::new().build()) { + log::warn!("Failed to initialize updater plugin: {}", e); + } // Register global shortcut from stored settings #[cfg(desktop)] diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index ce440aee..f278637c 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -1,278 +1,418 @@ -use tauri::{AppHandle, Manager, Position, Size}; -use tauri_nspanel::{ - CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel, -}; - -fn monitor_contains_physical_point( - origin_x: f64, - origin_y: f64, - width: f64, - height: f64, - point_x: f64, - point_y: f64, -) -> bool { - point_x >= origin_x - && point_x < origin_x + width - && point_y >= origin_y - && point_y < origin_y + height -} +#[cfg(target_os = "macos")] +pub use macos::*; -unsafe fn set_panel_frame_top_left(panel: &tauri_nspanel::NSPanel, x: f64, y: f64) { - let point = tauri_nspanel::NSPoint::new(x, y); - let _: () = objc2::msg_send![panel, setFrameTopLeftPoint: point]; -} +#[cfg(not(target_os = "macos"))] +pub use windows::*; -fn set_panel_top_left_immediately( - window: &tauri::WebviewWindow, - app_handle: &AppHandle, - panel_x: f64, - panel_y: f64, - primary_logical_h: f64, -) { - let Ok(panel_handle) = app_handle.get_webview_panel("main") else { - return; +#[cfg(target_os = "macos")] +mod macos { + use tauri::{AppHandle, Manager, Position, Size}; + use tauri_nspanel::{ + CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel, }; - let target_x = panel_x; - let target_y = primary_logical_h - panel_y; + fn monitor_contains_physical_point( + origin_x: f64, + origin_y: f64, + width: f64, + height: f64, + point_x: f64, + point_y: f64, + ) -> bool { + point_x >= origin_x + && point_x < origin_x + width + && point_y >= origin_y + && point_y < origin_y + height + } - if objc2_foundation::MainThreadMarker::new().is_some() { - unsafe { - set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); - } - return; + unsafe fn set_panel_frame_top_left(panel: &tauri_nspanel::NSPanel, x: f64, y: f64) { + let point = tauri_nspanel::NSPoint::new(x, y); + let _: () = objc2::msg_send![panel, setFrameTopLeftPoint: point]; } - let (tx, rx) = std::sync::mpsc::channel(); - let panel_handle = panel_handle.clone(); + fn set_panel_top_left_immediately( + window: &tauri::WebviewWindow, + app_handle: &AppHandle, + panel_x: f64, + panel_y: f64, + primary_logical_h: f64, + ) { + let Ok(panel_handle) = app_handle.get_webview_panel("main") else { + return; + }; + + let target_x = panel_x; + let target_y = primary_logical_h - panel_y; + + if objc2_foundation::MainThreadMarker::new().is_some() { + unsafe { + set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); + } + return; + } + + let (tx, rx) = std::sync::mpsc::channel(); + let panel_handle = panel_handle.clone(); - if let Err(error) = window.run_on_main_thread(move || { - unsafe { - set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); + if let Err(error) = window.run_on_main_thread(move || { + unsafe { + set_panel_frame_top_left(panel_handle.as_panel(), target_x, target_y); + } + let _ = tx.send(()); + }) { + log::warn!("Failed to position panel on main thread: {}", error); + return; } - let _ = tx.send(()); - }) { - log::warn!("Failed to position panel on main thread: {}", error); - return; - } - if rx.recv().is_err() { - log::warn!("Failed waiting for panel position on main thread"); + if rx.recv().is_err() { + log::warn!("Failed waiting for panel position on main thread"); + } } -} -/// Macro to get existing panel or initialize it if needed. -/// Returns Option - Some if panel is available, None on error. -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 - } else { - match $app_handle.get_webview_panel("main") { - Ok(panel) => Some(panel), - Err(err) => { - log::error!("Panel missing after init: {:?}", err); - None + 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 + } else { + match $app_handle.get_webview_panel("main") { + Ok(panel) => Some(panel), + Err(err) => { + log::error!("Panel missing after init: {:?}", err); + None + } } } } } - } - }; -} + }; + } -// Export macro for use in other modules -pub(crate) use get_or_init_panel; + pub(crate) use get_or_init_panel; -/// Retrieve the tray icon rect and position the panel beneath it. -/// No-ops gracefully if the tray icon or its rect is unavailable. -fn position_panel_from_tray(app_handle: &AppHandle) { - let Some(tray) = app_handle.tray_by_id("tray") else { - log::debug!("position_panel_from_tray: tray icon not found"); - return; - }; - match tray.rect() { - Ok(Some(rect)) => { - position_panel_at_tray_icon(app_handle, rect.position, rect.size); + fn position_panel_from_tray(app_handle: &AppHandle) { + let Some(tray) = app_handle.tray_by_id("tray") else { + log::debug!("position_panel_from_tray: tray icon not found"); + return; + }; + match tray.rect() { + Ok(Some(rect)) => { + position_panel_at_tray_icon(app_handle, rect.position, rect.size); + } + Ok(None) => { + log::debug!("position_panel_from_tray: tray rect not available yet"); + } + Err(e) => { + log::warn!("position_panel_from_tray: failed to get tray rect: {}", e); + } } - Ok(None) => { - log::debug!("position_panel_from_tray: tray rect not available yet"); + } + + pub fn show_panel(app_handle: &AppHandle) { + if let Some(panel) = get_or_init_panel!(app_handle) { + panel.show_and_make_key(); + if !crate::IS_PINNED.load(std::sync::atomic::Ordering::SeqCst) { + position_panel_from_tray(app_handle); + } } - Err(e) => { - log::warn!("position_panel_from_tray: failed to get tray rect: {}", e); + } + + pub fn hide_panel(app_handle: &AppHandle) { + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.hide(); } } -} -/// Show the panel (initializing if needed), positioned under the tray icon. -pub fn show_panel(app_handle: &AppHandle) { - if let Some(panel) = get_or_init_panel!(app_handle) { - panel.show_and_make_key(); - position_panel_from_tray(app_handle); + pub fn toggle_panel(app_handle: &AppHandle) { + let Some(panel) = get_or_init_panel!(app_handle) else { + return; + }; + + if panel.is_visible() { + log::debug!("toggle_panel: hiding panel"); + panel.hide(); + } else { + log::debug!("toggle_panel: showing panel"); + panel.show_and_make_key(); + if !crate::IS_PINNED.load(std::sync::atomic::Ordering::SeqCst) { + position_panel_from_tray(app_handle); + } + } } -} -/// Toggle panel visibility. If visible, hide it. If hidden, show it. -/// Used by global shortcut handler. -pub fn toggle_panel(app_handle: &AppHandle) { - let Some(panel) = get_or_init_panel!(app_handle) else { - return; - }; + tauri_panel! { + panel!(OpenUsagePanel { + config: { + can_become_key_window: true, + is_floating_panel: true + } + }) - if panel.is_visible() { - log::debug!("toggle_panel: hiding panel"); - panel.hide(); - } else { - log::debug!("toggle_panel: showing panel"); - panel.show_and_make_key(); - position_panel_from_tray(app_handle); + panel_event!(OpenUsagePanelEventHandler { + window_did_resign_key(notification: &NSNotification) -> () + }) } -} -// Define our panel class and event handler together -tauri_panel! { - panel!(OpenUsagePanel { - config: { - can_become_key_window: true, - is_floating_panel: true + pub fn init(app_handle: &tauri::AppHandle) -> tauri::Result<()> { + if app_handle.get_webview_panel("main").is_ok() { + return Ok(()); } - }) - panel_event!(OpenUsagePanelEventHandler { - window_did_resign_key(notification: &NSNotification) -> () - }) -} + let window = app_handle.get_webview_window("main").unwrap(); -pub fn init(app_handle: &tauri::AppHandle) -> tauri::Result<()> { - if app_handle.get_webview_panel("main").is_ok() { - return Ok(()); - } - - let window = app_handle.get_webview_window("main").unwrap(); + let panel = window.to_panel::()?; - let panel = window.to_panel::()?; + panel.set_has_shadow(false); + panel.set_opaque(false); + panel.set_level(PanelLevel::MainMenu.value() + 1); - // Disable native shadow - it causes gray border on transparent windows - // Let CSS handle shadow via shadow-xl class - panel.set_has_shadow(false); - panel.set_opaque(false); + panel.set_collection_behavior( + CollectionBehavior::new() + .move_to_active_space() + .full_screen_auxiliary() + .value(), + ); - // Configure panel behavior - panel.set_level(PanelLevel::MainMenu.value() + 1); + panel.set_style_mask(StyleMask::empty().nonactivating_panel().value()); - panel.set_collection_behavior( - CollectionBehavior::new() - .move_to_active_space() - .full_screen_auxiliary() - .value(), - ); + let event_handler = OpenUsagePanelEventHandler::new(); + let handle = app_handle.clone(); + event_handler.window_did_resign_key(move |_notification| { + if !crate::IS_PINNED.load(std::sync::atomic::Ordering::SeqCst) { + if let Ok(panel) = handle.get_webview_panel("main") { + panel.hide(); + } + } + }); - panel.set_style_mask(StyleMask::empty().nonactivating_panel().value()); + panel.set_event_handler(Some(event_handler.as_ref())); - // Set up event handler to hide panel when it loses focus - let event_handler = OpenUsagePanelEventHandler::new(); + Ok(()) + } - let handle = app_handle.clone(); - event_handler.window_did_resign_key(move |_notification| { - if let Ok(panel) = handle.get_webview_panel("main") { - panel.hide(); - } - }); + pub fn position_panel_at_tray_icon( + app_handle: &tauri::AppHandle, + icon_position: Position, + icon_size: Size, + ) { + let window = app_handle.get_webview_window("main").unwrap(); + + let (icon_phys_x, icon_phys_y) = match &icon_position { + Position::Physical(pos) => (pos.x as f64, pos.y as f64), + Position::Logical(pos) => (pos.x, pos.y), + }; + let (icon_phys_w, icon_phys_h) = match &icon_size { + Size::Physical(s) => (s.width as f64, s.height as f64), + Size::Logical(s) => (s.width, s.height), + }; + + let monitors = window.available_monitors().expect("failed to get monitors"); + let primary_logical_h = window + .primary_monitor() + .ok() + .flatten() + .map(|m| m.size().height as f64 / m.scale_factor()) + .unwrap_or(0.0); + + let icon_center_x = icon_phys_x + (icon_phys_w / 2.0); + let icon_center_y = icon_phys_y + (icon_phys_h / 2.0); + + let found_monitor = monitors.iter().find(|monitor| { + let origin = monitor.position(); + let size = monitor.size(); + monitor_contains_physical_point( + origin.x as f64, + origin.y as f64, + size.width as f64, + size.height as f64, + icon_center_x, + icon_center_y, + ) + }); + + let monitor = match found_monitor { + Some(m) => m.clone(), + None => { + log::warn!( + "No monitor found for tray rect center at ({:.0}, {:.0}), using primary", + icon_center_x, + icon_center_y + ); + match window.primary_monitor() { + Ok(Some(m)) => m, + _ => return, + } + } + }; + + let target_scale = monitor.scale_factor(); + let mon_phys_x = monitor.position().x as f64; + let mon_phys_y = monitor.position().y as f64; + let mon_logical_x = mon_phys_x / target_scale; + let mon_logical_y = mon_phys_y / target_scale; + + let icon_logical_x = mon_logical_x + (icon_phys_x - mon_phys_x) / target_scale; + let icon_logical_y = mon_logical_y + (icon_phys_y - mon_phys_y) / target_scale; + let icon_logical_w = icon_phys_w / target_scale; + let icon_logical_h = icon_phys_h / target_scale; + + let panel_width = match (window.outer_size(), window.scale_factor()) { + (Ok(s), Ok(win_scale)) => s.width as f64 / win_scale, + _ => { + let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json must be valid JSON"); + conf["app"]["windows"][0]["width"] + .as_f64() + .expect("width must be set in tauri.conf.json") + } + }; - panel.set_event_handler(Some(event_handler.as_ref())); + let icon_center_x = icon_logical_x + (icon_logical_w / 2.0); + let panel_x = icon_center_x - (panel_width / 2.0); + let nudge_up: f64 = 6.0; + let panel_y = icon_logical_y + icon_logical_h - nudge_up; - Ok(()) + set_panel_top_left_immediately(&window, app_handle, panel_x, panel_y, primary_logical_h); + } } -pub fn position_panel_at_tray_icon( - app_handle: &tauri::AppHandle, - icon_position: Position, - icon_size: Size, -) { - let window = app_handle.get_webview_window("main").unwrap(); +#[cfg(not(target_os = "macos"))] +mod windows { + use tauri::{AppHandle, Manager, Position, Size}; - let (icon_phys_x, icon_phys_y) = match &icon_position { - Position::Physical(pos) => (pos.x as f64, pos.y as f64), - Position::Logical(pos) => (pos.x, pos.y), - }; - let (icon_phys_w, icon_phys_h) = match &icon_size { - Size::Physical(s) => (s.width as f64, s.height as f64), - Size::Logical(s) => (s.width, s.height), - }; + macro_rules! get_window { + ($app_handle:expr) => { + $app_handle.get_webview_window("main") + }; + } - let monitors = window.available_monitors().expect("failed to get monitors"); - let primary_logical_h = window - .primary_monitor() - .ok() - .flatten() - .map(|m| m.size().height as f64 / m.scale_factor()) - .unwrap_or(0.0); - - let icon_center_x = icon_phys_x + (icon_phys_w / 2.0); - let icon_center_y = icon_phys_y + (icon_phys_h / 2.0); - - let found_monitor = monitors.iter().find(|monitor| { - let origin = monitor.position(); - let size = monitor.size(); - monitor_contains_physical_point( - origin.x as f64, - origin.y as f64, - size.width as f64, - size.height as f64, - icon_center_x, - icon_center_y, - ) - }); - - let monitor = match found_monitor { - Some(m) => m.clone(), - None => { - log::warn!( - "No monitor found for tray rect center at ({:.0}, {:.0}), using primary", - icon_center_x, - icon_center_y - ); - match window.primary_monitor() { - Ok(Some(m)) => m, - _ => return, + pub fn show_panel(app_handle: &AppHandle) { + if let Some(window) = get_window!(app_handle) { + let _ = window.show(); + let _ = window.set_focus(); + if !crate::IS_PINNED.load(std::sync::atomic::Ordering::SeqCst) { + position_panel_from_tray(app_handle); } } - }; + } - let target_scale = monitor.scale_factor(); - let mon_phys_x = monitor.position().x as f64; - let mon_phys_y = monitor.position().y as f64; - let mon_logical_x = mon_phys_x / target_scale; - let mon_logical_y = mon_phys_y / target_scale; - - let icon_logical_x = mon_logical_x + (icon_phys_x - mon_phys_x) / target_scale; - let icon_logical_y = mon_logical_y + (icon_phys_y - mon_phys_y) / target_scale; - let icon_logical_w = icon_phys_w / target_scale; - let icon_logical_h = icon_phys_h / target_scale; - - // Read panel width from the window, converted to logical points. - // outer_size() returns physical pixels at the window's current scale factor. - // If the window isn't available yet, parse the configured width from tauri.conf.json - // (embedded at compile time) so it stays in sync automatically. - let panel_width = match (window.outer_size(), window.scale_factor()) { - (Ok(s), Ok(win_scale)) => s.width as f64 / win_scale, - _ => { - let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) - .expect("tauri.conf.json must be valid JSON"); - conf["app"]["windows"][0]["width"] - .as_f64() - .expect("width must be set in tauri.conf.json") + pub fn hide_panel(app_handle: &AppHandle) { + if let Some(window) = get_window!(app_handle) { + let _ = window.hide(); } - }; + } - let icon_center_x = icon_logical_x + (icon_logical_w / 2.0); - let panel_x = icon_center_x - (panel_width / 2.0); - let nudge_up: f64 = 6.0; - let panel_y = icon_logical_y + icon_logical_h - nudge_up; + pub fn toggle_panel(app_handle: &AppHandle) { + let Some(window) = get_window!(app_handle) else { + return; + }; + + if window.is_visible().unwrap_or(false) { + log::debug!("toggle_panel: hiding window"); + let _ = window.hide(); + } else { + log::debug!("toggle_panel: showing window"); + let _ = window.show(); + let _ = window.set_focus(); + if !crate::IS_PINNED.load(std::sync::atomic::Ordering::SeqCst) { + position_panel_from_tray(app_handle); + } + } + } - set_panel_top_left_immediately(&window, app_handle, panel_x, panel_y, primary_logical_h); + pub fn init(app_handle: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = get_window!(app_handle) { + let _ = window.set_decorations(false); + let _ = window.set_always_on_top(true); + let _ = window.set_skip_taskbar(true); + + // Handle window blur to hide it + let handle = app_handle.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(false) = event { + if !crate::IS_PINNED.load(std::sync::atomic::Ordering::SeqCst) { + if let Some(w) = get_window!(handle) { + let _ = w.hide(); + } + } + } + }); + } + Ok(()) + } + + fn position_panel_from_tray(app_handle: &AppHandle) { + let Some(tray) = app_handle.tray_by_id("tray") else { + return; + }; + match tray.rect() { + Ok(Some(rect)) => { + position_panel_at_tray_icon(app_handle, rect.position, rect.size); + } + _ => {} + } + } + + pub fn position_panel_at_tray_icon( + app_handle: &tauri::AppHandle, + icon_position: Position, + icon_size: Size, + ) { + let window = app_handle.get_webview_window("main").unwrap(); + + let (icon_phys_x, icon_phys_y) = match &icon_position { + Position::Physical(pos) => (pos.x as f64, pos.y as f64), + Position::Logical(pos) => (pos.x, pos.y), + }; + let (icon_phys_w, icon_phys_h) = match &icon_size { + Size::Physical(s) => (s.width as f64, s.height as f64), + Size::Logical(s) => (s.width, s.height), + }; + + let outer_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(400, 500)); + let window_phys_w = outer_size.width as f64; + let window_phys_h = outer_size.height as f64; + + let monitors = window.available_monitors().unwrap_or_default(); + let icon_center_x = icon_phys_x + (icon_phys_w / 2.0); + let icon_center_y = icon_phys_y + (icon_phys_h / 2.0); + + let mut monitor_bounds = None; + for monitor in monitors { + let pos = monitor.position(); + let size = monitor.size(); + if icon_center_x >= pos.x as f64 && icon_center_x <= (pos.x + size.width as i32) as f64 + && icon_center_y >= pos.y as f64 && icon_center_y <= (pos.y + size.height as i32) as f64 { + monitor_bounds = Some((*pos, *size)); + break; + } + } + + let gap = 10.0; + let mut target_x = icon_center_x - (window_phys_w / 2.0); + let mut target_y = icon_phys_y - window_phys_h - gap; + + if let Some((pos, size)) = monitor_bounds { + let mon_right = pos.x as f64 + size.width as f64; + if target_x + window_phys_w > mon_right { + target_x = mon_right - window_phys_w; + } + if target_x < pos.x as f64 { + target_x = pos.x as f64; + } + // Ensure we don't go below screen bounds either (e.g. if tray is at top somehow) + if target_y < pos.y as f64 { + // place below icon instead + target_y = icon_phys_y + icon_phys_h + gap; + } + } + + let _ = window.set_position(tauri::Position::Physical(tauri::PhysicalPosition::new( + target_x as i32, + target_y as i32, + ))); + } } diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index be532de3..7735757f 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -127,7 +127,7 @@ fn read_env_from_process(name: &str) -> Option { } fn read_command_stdout(program: &str, args: &[&str]) -> Option { - let output = Command::new(program).args(args).output().ok()?; + let output = crate::utils::command_new(program).args(args).output().ok()?; if !output.status.success() { return None; } @@ -1306,10 +1306,21 @@ fn inject_ls<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquick opts.markers ); - let ps_output = match std::process::Command::new("/bin/ps") + #[cfg(windows)] + let ps_output_res = crate::utils::command_new("powershell") + .args([ + "-NoProfile", + "-Command", + "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.CommandLine)\" }", + ]) + .output(); + + #[cfg(not(windows))] + let ps_output_res = crate::utils::command_new("/bin/ps") .args(["-ax", "-o", "pid=,command="]) - .output() - { + .output(); + + let ps_output = match ps_output_res { Ok(o) => o, Err(e) => { log::warn!("[plugin:{}] ps failed: {}", pid, e); @@ -1416,39 +1427,88 @@ fn inject_ls<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquick } } - // 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 for the discovered process + #[cfg(windows)] + let ports = { + match crate::utils::command_new("netstat") + .args(["-ano", "-p", "TCP"]) .output() { Ok(o) if o.status.success() => { - ls_parse_listening_ports(&String::from_utf8_lossy(&o.stdout)) + let stdout = String::from_utf8_lossy(&o.stdout); + let pid_str = process_pid.to_string(); + let mut found_ports = std::collections::BTreeSet::new(); + for line in stdout.lines() { + let line = line.trim(); + if !line.contains("LISTENING") { + continue; + } + // netstat -ano format: TCP 0.0.0.0:PORT 0.0.0.0:0 LISTENING PID + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.len() < 5 { + continue; + } + let line_pid = tokens[tokens.len() - 1]; + if line_pid != pid_str { + continue; + } + // Extract port from local address (e.g. "0.0.0.0:42010" or "[::]:42010") + if let Some(colon_pos) = tokens[1].rfind(':') { + if let Ok(port) = tokens[1][colon_pos + 1..].parse::() { + if port > 0 && port < 65536 { + found_ports.insert(port); + } + } + } + } + found_ports.into_iter().collect::>() } 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() + }; + + #[cfg(not(windows))] + let ports = { + 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 crate::utils::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() { @@ -1825,7 +1885,7 @@ fn ccusage_enriched_path() -> Option { } fn ccusage_runner_available(candidate: &str, enriched_path: Option<&OsStr>) -> bool { - let mut command = std::process::Command::new(candidate); + let mut command = crate::utils::command_new(candidate); command.arg("--version"); if let Some(path) = enriched_path { command.env("PATH", path); @@ -2130,7 +2190,7 @@ fn run_ccusage_with_runner_timeout( ) -> CcusageRunnerResult { let args = ccusage_runner_args(kind, opts, provider, flavor); let enriched_path = ccusage_enriched_path(); - let mut command = std::process::Command::new(program); + let mut command = crate::utils::command_new(program); configure_ccusage_command(&mut command, &args, enriched_path.as_deref()); if let Some(home_path) = ccusage_home_override(opts, provider) { @@ -2395,7 +2455,7 @@ fn inject_keychain<'js>( )); } log::info!("[plugin:{}] keychain read: service={}", pid_read, service); - let output = std::process::Command::new("security") + let output = crate::utils::command_new("security") .args(keychain_find_generic_password_args(&service)) .output() .map_err(|e| { @@ -2451,7 +2511,7 @@ fn inject_keychain<'js>( service, redacted_account ); - let output = std::process::Command::new("security") + let output = crate::utils::command_new("security") .args(&args) .output() .map_err(|e| { @@ -2503,7 +2563,7 @@ fn inject_keychain<'js>( log::info!("[plugin:{}] keychain write: service={}", pid_write, service); let mut account_arg: Option = None; - let find_output = std::process::Command::new("security") + let find_output = crate::utils::command_new("security") .args(["find-generic-password", "-s", &service]) .output(); @@ -2523,13 +2583,13 @@ fn inject_keychain<'js>( } let output = if let Some(ref acct) = account_arg { - std::process::Command::new("security") + crate::utils::command_new("security") .args(keychain_add_generic_password_args_for_account( &service, acct, &value, )) .output() } else { - std::process::Command::new("security") + crate::utils::command_new("security") .args(keychain_add_generic_password_args(&service, &value)) .output() } @@ -2584,7 +2644,7 @@ fn inject_keychain<'js>( service, redacted_account ); - let output = std::process::Command::new("security") + let output = crate::utils::command_new("security") .args(&args) .output() .map_err(|e| { @@ -2641,47 +2701,52 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() } let expanded = expand_path(&db_path); - // Prefer a normal read-only open so WAL contents are visible (common for app state DBs). - // Fall back to immutable=1 to bypass WAL/SHM lock issues after macOS sleep. - let primary = std::process::Command::new("sqlite3") - .args(["-readonly", "-json", &expanded, &sql]) - .output() - .map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) - })?; - - if primary.status.success() { - return Ok(String::from_utf8_lossy(&primary.stdout).to_string()); - } - - // Percent-encode special chars for valid URI (% must be first!) - let encoded = expanded - .replace('%', "%25") - .replace(' ', "%20") - .replace('#', "%23") - .replace('?', "%3F"); - let uri_path = format!("file:{}?immutable=1", encoded); - let fallback = 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 = match rusqlite::Connection::open_with_flags( + &expanded, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) { + Ok(c) => c, + Err(_) => { + let encoded = expanded + .replace('%', "%25") + .replace(' ', "%20") + .replace('#', "%23") + .replace('?', "%3F"); + let uri_path = format!("file:{}?immutable=1", encoded); + rusqlite::Connection::open_with_flags( + &uri_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ).map_err(|e| Exception::throw_message(&ctx_inner, &format!("sqlite3 open failed: {}", e)))? + } + }; - if !fallback.status.success() { - let stderr_primary = String::from_utf8_lossy(&primary.stderr); - let stderr_fallback = String::from_utf8_lossy(&fallback.stderr); - return Err(Exception::throw_message( - &ctx_inner, - &format!( - "sqlite3 error: {} (fallback: {})", - stderr_primary.trim(), - stderr_fallback.trim() - ), - )); + let mut stmt = conn.prepare(&sql).map_err(|e| Exception::throw_message(&ctx_inner, &format!("sqlite3 prepare failed: {}", e)))?; + + let column_names: Vec = stmt.column_names().into_iter().map(|s| s.to_string()).collect(); + let column_count = column_names.len(); + + let mut rows = stmt.query([]).map_err(|e| Exception::throw_message(&ctx_inner, &format!("sqlite3 query failed: {}", e)))?; + let mut result_json = Vec::new(); + + while let Some(row) = rows.next().map_err(|e| Exception::throw_message(&ctx_inner, &format!("sqlite3 fetch failed: {}", e)))? { + let mut row_map = serde_json::Map::new(); + for i in 0..column_count { + let col_name = column_names[i].clone(); + let value: rusqlite::types::Value = row.get(i).map_err(|e| Exception::throw_message(&ctx_inner, &format!("sqlite3 value failed: {}", e)))?; + let json_val = match value { + rusqlite::types::Value::Null => serde_json::Value::Null, + rusqlite::types::Value::Integer(v) => serde_json::Value::Number(v.into()), + rusqlite::types::Value::Real(v) => serde_json::Number::from_f64(v).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null), + rusqlite::types::Value::Text(v) => serde_json::Value::String(v), + rusqlite::types::Value::Blob(v) => serde_json::Value::String(String::from_utf8_lossy(&v).to_string()), + }; + row_map.insert(col_name, json_val); + } + result_json.push(serde_json::Value::Object(row_map)); } - - Ok(String::from_utf8_lossy(&fallback.stdout).to_string()) + + let json_str = serde_json::to_string(&result_json).map_err(|e| Exception::throw_message(&ctx_inner, &format!("sqlite3 serialize failed: {}", e)))?; + Ok(json_str) }, )?, )?; @@ -2698,20 +2763,12 @@ 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 = rusqlite::Connection::open(&expanded) + .map_err(|e| Exception::throw_message(&ctx_inner, &format!("sqlite3 open failed: {}", e)))?; + + conn.execute_batch(&sql) + .map_err(|e| Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)))?; Ok(()) }, @@ -3926,7 +3983,7 @@ mod tests { #[test] fn configure_ccusage_command_sets_path_override() { - let mut command = std::process::Command::new("echo"); + let mut command = crate::utils::command_new("echo"); let args = vec!["daily".to_string(), "--json".to_string()]; let path = std::env::join_paths([ std::path::PathBuf::from("/tmp/bin"), @@ -3951,7 +4008,7 @@ mod tests { #[test] fn configure_ccusage_command_skips_path_override_when_absent() { - let mut command = std::process::Command::new("echo"); + let mut command = crate::utils::command_new("echo"); let args = vec!["daily".to_string()]; configure_ccusage_command(&mut command, &args, None); @@ -4321,3 +4378,4 @@ wait let _ = std::fs::remove_dir_all(&dir); } } + diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 545f29b0..a0ce56f0 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -3,10 +3,9 @@ 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::{get_or_init_panel, position_panel_at_tray_icon, show_panel}; +use crate::panel::{show_panel, toggle_panel}; const LOG_LEVEL_STORE_KEY: &str = "logLevel"; @@ -183,24 +182,11 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { let app_handle = tray.app_handle(); if let TrayIconEvent::Click { - button_state, rect, .. + button_state, .. } = event { if button_state == MouseButtonState::Up { - let Some(panel) = get_or_init_panel!(app_handle) else { - return; - }; - - if panel.is_visible() { - log::debug!("tray click: hiding panel"); - panel.hide(); - return; - } - 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); + toggle_panel(app_handle); } } }) diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs new file mode 100644 index 00000000..f6175f9e --- /dev/null +++ b/src-tauri/src/utils.rs @@ -0,0 +1,12 @@ +use std::process::Command; + +pub fn command_new>(program: S) -> Command { + let mut cmd = Command::new(program); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index badb8861..a6cb739a 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.24", + "version": "0.6.39", "identifier": "com.sunstory.openusage", "build": { "beforeDevCommand": "bun run bundle:plugins && bun run dev", @@ -16,9 +16,10 @@ "title": "OpenUsage", "width": 400, "height": 500, - "resizable": false, + "resizable": true, "decorations": false, "transparent": true, + "shadow": false, "visible": false } ], diff --git a/src/components/app/app-shell.tsx b/src/components/app/app-shell.tsx index 8de76733..b7a1573d 100644 --- a/src/components/app/app-shell.tsx +++ b/src/components/app/app-shell.tsx @@ -65,16 +65,19 @@ export function AppShell({ const appVersion = useAppVersion() const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() - + const isWindows = navigator.userAgent.includes("Windows") return (
-
+ {!isWindows &&
}
diff --git a/src/components/side-nav.tsx b/src/components/side-nav.tsx index b363e888..5fc82682 100644 --- a/src/components/side-nav.tsx +++ b/src/components/side-nav.tsx @@ -1,7 +1,8 @@ -import { useCallback } from "react" -import { CircleHelp, Settings } from "lucide-react" +import { useCallback, useEffect, useState } from "react" +import { CircleHelp, Settings, Pin, PinOff, Power } from "lucide-react" import { openUrl } from "@tauri-apps/plugin-opener" import { invoke } from "@tauri-apps/api/core" +import { exit } from "@tauri-apps/plugin-process" import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu" import { DndContext, @@ -148,6 +149,17 @@ export function SideNav({ onReorder, }: SideNavProps) { const isDark = useDarkMode() + const [isPinned, setIsPinned] = useState(false) + + useEffect(() => { + invoke("is_pinned").then(setIsPinned).catch(console.error) + }, []) + + const togglePin = () => { + const next = !isPinned + setIsPinned(next) + invoke("set_pinned", { pinned: next }).catch(console.error) + } const sensors = useSensors( useSensor(PointerSensor, { @@ -213,9 +225,11 @@ export function SideNav({ }, [isPluginRefreshAvailable, onPluginContextAction] ) - return ( -