From e3becbda53414b9ff759408a95ef54f760dcafef Mon Sep 17 00:00:00 2001 From: Symon Date: Sun, 31 May 2026 15:22:46 +0300 Subject: [PATCH] Add Linux support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make OpenUsage build and run on Linux (X11/Wayland) alongside macOS, and publish Linux packages from CI. macOS behavior is unchanged — all macOS-only code stays under #[cfg(target_os = "macos")]. - panel.rs: split into a macOS NSPanel implementation (unchanged) and a non-macOS one using a plain always-on-top borderless window that hides on focus loss, behind a shared API. Positioning under the tray icon is best-effort (Wayland can't always honor it). - Cargo.toml: gate tauri-nspanel + objc2* to macOS only. - lib.rs: cfg-gate nspanel init; hide-on-blur for non-macOS; cross-platform log path via app_log_dir() instead of ~/Library/Logs. - tray.rs: route show/toggle through the panel module instead of NSPanel. - host_api.rs: read the system keyring via libsecret `secret-tool` on Linux; add Linux ccusage runner paths (~/.cargo/bin, ~/.asdf/shims, /usr/bin); close the temp script handle before exec in tests (Linux ETXTBSY). - plugins (cursor/windsurf/kiro/antigravity/gemini): resolve app-data paths per ctx.app.platform (~/Library/Application Support -> ~/.config on Linux). - CI: build AppImage/.deb/.rpm on ubuntu-22.04 and run cargo test on Linux; build macOS unsigned when no Apple certs are configured (sign when they are); set APPIMAGE_EXTRACT_AND_RUN for FUSE-less runners. - README: Linux install/build notes and the tray left-click caveat. --- .github/workflows/ci.yml | 34 ++ .github/workflows/publish.yml | 51 ++- README.md | 59 +++- plugins/antigravity/plugin.js | 11 +- plugins/cursor/plugin.js | 17 +- plugins/gemini/plugin.js | 3 + plugins/kiro/plugin.js | 28 +- plugins/windsurf/plugin.js | 19 +- src-tauri/Cargo.toml | 3 +- src-tauri/src/lib.rs | 39 ++- src-tauri/src/panel.rs | 415 ++++++++++++++--------- src-tauri/src/plugin_engine/host_api.rs | 424 ++++++++++++++---------- src-tauri/src/tray.rs | 27 +- 13 files changed, 733 insertions(+), 397 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50cc26a6..9ca4394e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,37 @@ jobs: - name: Run tests run: bun run test + + rust: + name: Rust build & test (Linux) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + - uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libsecret-1-dev \ + build-essential + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install + - name: Bundle plugins + run: bun run bundle:plugins + + - name: Cargo test + working-directory: src-tauri + run: cargo test --locked diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c15fd3dd..6d51bf8d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,11 +17,21 @@ jobs: include: - platform: macos-latest args: "--target aarch64-apple-darwin" + rust_targets: "aarch64-apple-darwin" - platform: macos-latest args: "--target x86_64-apple-darwin" + rust_targets: "x86_64-apple-darwin" + - platform: ubuntu-22.04 + args: "" + rust_targets: "x86_64-unknown-linux-gnu" runs-on: ${{ matrix.platform }} env: RELEASE_TAG: ${{ github.ref_name }} + # GitHub runners lack FUSE; make linuxdeploy/AppImage tooling extract-and-run. + APPIMAGE_EXTRACT_AND_RUN: 1 + # Whether Apple code-signing secrets are configured (forks usually have none). + # When false the macOS job builds an unsigned app instead of failing. + HAS_APPLE_CERT: ${{ secrets.APPLE_CERTIFICATE != '' }} steps: - uses: actions/checkout@v4 with: @@ -29,11 +39,24 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin,x86_64-apple-darwin + targets: ${{ matrix.rust_targets }} - uses: swatinem/rust-cache@v2 with: workspaces: "./src-tauri -> target" + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf \ + libsecret-1-dev \ + build-essential + - uses: oven-sh/setup-bun@v2 with: bun-version: "latest" @@ -80,11 +103,16 @@ jobs: exit 1 fi - - name: Import Apple Developer Certificate + - name: Import Apple Developer Certificate & enable signing + if: runner.os == 'macOS' && env.HAS_APPLE_CERT == 'true' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain @@ -94,6 +122,15 @@ jobs: security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain rm certificate.p12 + # Only expose the Apple signing vars to tauri-action when a cert exists. + # If they are set but empty, Tauri tries to codesign with identity "" and fails; + # leaving them unset makes Tauri ad-hoc sign, which works for unsigned releases. + { + echo "APPLE_SIGNING_IDENTITY=$APPLE_SIGNING_IDENTITY" + echo "APPLE_ID=$APPLE_ID" + echo "APPLE_PASSWORD=$APPLE_PASSWORD" + echo "APPLE_TEAM_ID=$APPLE_TEAM_ID" + } >> "$GITHUB_ENV" - uses: tauri-apps/tauri-action@v0 env: @@ -101,14 +138,8 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + # Apple signing vars are injected via $GITHUB_ENV by the step above, + # and only when a certificate is configured. Do not set them here. with: tagName: ${{ env.RELEASE_TAG }} releaseName: ${{ env.RELEASE_TAG }} diff --git a/README.md b/README.md index 29885b01..e08d27a0 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,53 @@ See your usage at a glance from your menu bar. No digging through dashboards. ## Download -[**Download the latest release**](https://github.com/robinebers/openusage/releases/latest) (macOS, Apple Silicon & Intel) +[**Download the latest release**](https://github.com/robinebers/openusage/releases/latest) (macOS Apple Silicon & Intel, Linux AppImage/.deb/.rpm) The app auto-updates. Install once and you're set. +> **Linux notes:** OpenUsage runs in the system tray. On desktops without +> StatusNotifierItem/AppIndicator support (e.g. GNOME without the AppIndicator +> extension) a left-click on the tray icon may not open the panel — use the +> tray menu's **Show Stats** entry or the global shortcut instead. The panel +> appears under the tray icon where the compositor allows it (best-effort on +> Wayland). Reading credentials stored in the system keyring requires +> `secret-tool` (the `libsecret`/`libsecret-tools` package). + +## Install on Linux + +Grab the asset for your distro from the +[latest release](https://github.com/robinebers/openusage/releases/latest) +(replace the version in the examples below if a newer one is out). + +**Fedora / RHEL (.rpm)** — `dnf` pulls in the dependencies automatically: + +```sh +sudo dnf install https://github.com/robinebers/openusage/releases/download/v0.6.24/OpenUsage-0.6.24-1.x86_64.rpm +``` + +**Debian / Ubuntu (.deb):** + +```sh +curl -LO https://github.com/robinebers/openusage/releases/download/v0.6.24/OpenUsage_0.6.24_amd64.deb +sudo apt install ./OpenUsage_0.6.24_amd64.deb +``` + +**Any distro (AppImage)** — portable, no install: + +```sh +curl -L -o OpenUsage.AppImage https://github.com/robinebers/openusage/releases/download/v0.6.24/OpenUsage_0.6.24_amd64.AppImage +chmod +x OpenUsage.AppImage +./OpenUsage.AppImage +``` + +After installing via `.rpm`/`.deb`, launch **OpenUsage** from your app menu — it +starts in the system tray. If your desktop has no tray (e.g. GNOME), install an +AppIndicator/StatusNotifier extension, or open the panel with the global shortcut. + +Runtime dependencies (handled automatically by `.rpm`/`.deb`): `webkit2gtk-4.1`, +`gtk3`, an AppIndicator library, and — for providers that read the system +keyring — `secret-tool` (`libsecret-tools` / `libsecret`). + ## What It Does OpenUsage lives in your menu bar and shows you how much of your AI coding subscriptions you've used. Progress bars, badges, and clear labels. No mental math required. @@ -98,6 +141,20 @@ Inspired by [CodexBar](https://github.com/steipete/CodexBar) by [@steipete](http > **Warning**: The `main` branch may not be stable. It is merged directly without staging, so users are advised to use tagged versions for stable builds. Tagged versions are fully tested while `main` may contain unreleased features. +### Linux build prerequisites + +Install the system libraries Tauri needs before building: + +```sh +# Debian/Ubuntu +sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev \ + libayatana-appindicator3-dev librsvg2-dev patchelf libsecret-1-dev build-essential + +# Fedora +sudo dnf install -y webkit2gtk4.1-devel gtk3-devel libappindicator-gtk3-devel \ + librsvg2-devel libsecret-devel patchelf +``` + ### Stack ... diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 2b8d515b..55b66d26 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -1,6 +1,13 @@ (function () { var LS_SERVICE = "exa.language_server_pb.LanguageServerService" - var STATE_DB = "~/Library/Application Support/Antigravity/User/globalStorage/state.vscdb" + // Antigravity's app data location varies per OS (VS Code-style layout). + function stateDbPath(ctx) { + var platform = ctx.app && ctx.app.platform + var suffix = "/Antigravity/User/globalStorage/state.vscdb" + if (platform === "linux") return "~/.config" + suffix + if (platform === "windows") return "~/AppData/Roaming" + suffix + return "~/Library/Application Support" + suffix + } var CLOUD_CODE_URLS = [ "https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com", @@ -95,7 +102,7 @@ function loadOAuthTokens(ctx) { try { var rows = ctx.host.sqlite.query( - STATE_DB, + stateDbPath(ctx), "SELECT value FROM ItemTable WHERE key = '" + OAUTH_TOKEN_KEY + "' LIMIT 1" ) var parsed = ctx.util.tryParseJson(rows) diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 4229919d..f06c4bb1 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -1,6 +1,15 @@ (function () { - const STATE_DB = - "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + // VS Code-style app data location varies per OS. + function stateDbPath(ctx) { + const platform = ctx.app && ctx.app.platform + if (platform === "linux") { + return "~/.config/Cursor/User/globalStorage/state.vscdb" + } + if (platform === "windows") { + return "~/AppData/Roaming/Cursor/User/globalStorage/state.vscdb" + } + return "~/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" @@ -18,7 +27,7 @@ try { 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(stateDbPath(ctx), sql) const rows = ctx.util.tryParseJson(json) if (!Array.isArray(rows)) { throw new Error("sqlite returned invalid json") @@ -42,7 +51,7 @@ "', '" + escaped + "');" - ctx.host.sqlite.exec(STATE_DB, sql) + ctx.host.sqlite.exec(stateDbPath(ctx), sql) return true } catch (e) { ctx.host.log.warn("sqlite write failed for " + key + ": " + String(e)) diff --git a/plugins/gemini/plugin.js b/plugins/gemini/plugin.js index a86c0966..df360e7b 100644 --- a/plugins/gemini/plugin.js +++ b/plugins/gemini/plugin.js @@ -9,7 +9,9 @@ "~/.bun/install/global/node_modules", "~/.npm-global/lib/node_modules", "/usr/local/lib/node_modules", + "/usr/lib/node_modules", "~/Library/pnpm/global/5/node_modules", + "~/.local/share/pnpm/global/5/node_modules", ] const STATIC_NESTED_ONLY = [ @@ -20,6 +22,7 @@ const VERSION_MANAGER_ROOTS = [ { root: "~/.nvm/versions/node", modulePath: "/lib/node_modules" }, { root: "~/Library/Application Support/fnm/node-versions", modulePath: "/installation/lib/node_modules" }, + { root: "~/.local/share/fnm/node-versions", modulePath: "/installation/lib/node_modules" }, ] function listDirSafe(ctx, path) { diff --git a/plugins/kiro/plugin.js b/plugins/kiro/plugin.js index c9625371..2d1807c0 100644 --- a/plugins/kiro/plugin.js +++ b/plugins/kiro/plugin.js @@ -1,10 +1,23 @@ (function () { - const STATE_DB = "~/Library/Application Support/Kiro/User/globalStorage/state.vscdb" + // Kiro's app data location varies per OS (VS Code-style layout). + function kiroBase(ctx) { + const platform = ctx.app && ctx.app.platform + if (platform === "linux") return "~/.config/Kiro" + if (platform === "windows") return "~/AppData/Roaming/Kiro" + return "~/Library/Application Support/Kiro" + } + function stateDbPath(ctx) { + return kiroBase(ctx) + "/User/globalStorage/state.vscdb" + } + function logsRoot(ctx) { + return kiroBase(ctx) + "/logs" + } + function profilePath(ctx) { + return kiroBase(ctx) + "/User/globalStorage/kiro.kiroagent/profile.json" + } const STATE_KEY = "kiro.kiroAgent" - const LOGS_ROOT = "~/Library/Application Support/Kiro/logs" const LOG_FILE_NAME = "q-client.log" const TOKEN_PATH = "~/.aws/sso/cache/kiro-auth-token.json" - const PROFILE_PATH = "~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/profile.json" const REFRESH_URL = "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken" const LIVE_STALE_MS = 15 * 60 * 1000 const REFRESH_BUFFER_MS = 10 * 60 * 1000 @@ -77,7 +90,7 @@ function loadProfileArn(ctx, authState) { const fromToken = authState && authState.token && authState.token.profileArn if (typeof fromToken === "string" && fromToken) return fromToken - const parsed = readJsonFile(ctx, PROFILE_PATH, "profile") + const parsed = readJsonFile(ctx, profilePath(ctx), "profile") return parsed && typeof parsed.arn === "string" && parsed.arn.trim() ? parsed.arn.trim() : null } function regionFromArn(profileArn) { @@ -87,7 +100,7 @@ function readStateValue(ctx, key) { try { const sql = "SELECT value FROM ItemTable WHERE key = '" + String(key).replace(/'/g, "''") + "' LIMIT 1;" - const rows = ctx.util.tryParseJson(ctx.host.sqlite.query(STATE_DB, sql)) + const rows = ctx.util.tryParseJson(ctx.host.sqlite.query(stateDbPath(ctx), sql)) return Array.isArray(rows) && rows.length && typeof rows[0].value === "string" ? rows[0].value : null } catch (e) { ctx.host.log.warn("Kiro sqlite read failed: " + String(e)) @@ -186,13 +199,14 @@ } function loadLoggedState(ctx) { let sessions = [] + const logs = logsRoot(ctx) try { - sessions = ctx.host.fs.listDir(LOGS_ROOT).slice().sort().reverse() + sessions = ctx.host.fs.listDir(logs).slice().sort().reverse() } catch { return null } for (let i = 0; i < sessions.length && i < 12; i += 1) { - const sessionRoot = LOGS_ROOT + "/" + sessions[i] + const sessionRoot = logs + "/" + sessions[i] let windows = [] try { windows = ctx.host.fs.listDir(sessionRoot).slice().sort().reverse() diff --git a/plugins/windsurf/plugin.js b/plugins/windsurf/plugin.js index eb167b60..fe999e39 100644 --- a/plugins/windsurf/plugin.js +++ b/plugins/windsurf/plugin.js @@ -11,15 +11,28 @@ { marker: "windsurf", ideName: "windsurf", - stateDb: "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb", + appDir: "Windsurf", }, { marker: "windsurf-next", ideName: "windsurf-next", - stateDb: "~/Library/Application Support/Windsurf - Next/User/globalStorage/state.vscdb", + appDir: "Windsurf - Next", }, ] + // VS Code-style app data location varies per OS. + function stateDbPath(ctx, appDir) { + var platform = ctx.app && ctx.app.platform + var suffix = "/" + appDir + "/User/globalStorage/state.vscdb" + if (platform === "linux") { + return "~/.config" + suffix + } + if (platform === "windows") { + return "~/AppData/Roaming" + suffix + } + return "~/Library/Application Support" + suffix + } + function readFiniteNumber(value) { if (typeof value === "number") return Number.isFinite(value) ? value : null if (typeof value !== "string") return null @@ -39,7 +52,7 @@ function loadApiKey(ctx, variant) { try { var rows = ctx.host.sqlite.query( - variant.stateDb, + stateDbPath(ctx, variant.appDir), "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..a2ea9842 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,6 @@ tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-pn tauri-plugin-opener = "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" @@ -44,6 +43,8 @@ aes-gcm = "0.10.3" sha2 = "0.11" [target.'cfg(target_os = "macos")'.dependencies] +# nspanel powers the floating menubar panel; macOS-only. +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/src/lib.rs b/src-tauri/src/lib.rs index 702aa988..278d684e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -200,10 +200,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] @@ -380,10 +377,13 @@ async fn start_probe_batch( #[tauri::command] fn get_log_path(app_handle: tauri::AppHandle) -> Result { - // macOS log directory: ~/Library/Logs/{bundleIdentifier} - let home = dirs::home_dir().ok_or("no home dir")?; - let bundle_id = app_handle.config().identifier.clone(); - let log_dir = home.join("Library").join("Logs").join(&bundle_id); + use tauri::Manager; + // Cross-platform log directory (matches where tauri-plugin-log writes): + // macOS ~/Library/Logs/{id}, Linux ~/.local/share/{id}/logs, Windows AppData. + let log_dir = app_handle + .path() + .app_log_dir() + .map_err(|e| format!("no log dir: {}", e))?; let log_file = log_dir.join(format!("{}.log", app_handle.package_info().name)); Ok(log_file.to_string_lossy().to_string()) } @@ -504,11 +504,15 @@ pub fn run() { let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let _guard = runtime.enter(); - tauri::Builder::default() + let 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")] + let builder = builder.plugin(tauri_nspanel::init()); + + builder .plugin( tauri_plugin_log::Builder::new() .targets([ @@ -547,6 +551,19 @@ pub fn run() { use tauri::Manager; + // On non-macOS the panel is a regular window (no NSPanel focus-loss + // event), so hide it ourselves when it loses focus to mimic the + // menubar-popup behavior. + #[cfg(not(target_os = "macos"))] + if let Some(window) = app.get_webview_window("main") { + let win = window.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(false) = event { + let _ = win.hide(); + } + }); + } + let version = app.package_info().version.to_string(); log::info!("OpenUsage v{} starting", version); diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index ce440aee..cd923f2f 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -1,4 +1,6 @@ use tauri::{AppHandle, Manager, Position, Size}; + +#[cfg(target_os = "macos")] use tauri_nspanel::{ CollectionBehavior, ManagerExt, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel, }; @@ -17,77 +19,6 @@ fn monitor_contains_physical_point( && point_y < origin_y + height } -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]; -} - -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); - } - 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"); - } -} - -/// 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 - } - } - } - } - } - }; -} - -// Export macro for use in other modules -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) { @@ -108,92 +39,16 @@ fn position_panel_from_tray(app_handle: &AppHandle) { } } -/// 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); - } -} - -/// 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; - }; - - 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); - } -} - -// Define our panel class and event handler together -tauri_panel! { - panel!(OpenUsagePanel { - config: { - can_become_key_window: true, - is_floating_panel: true - } - }) - - panel_event!(OpenUsagePanelEventHandler { - window_did_resign_key(notification: &NSNotification) -> () - }) -} - -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::()?; - - // 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); - - // Configure panel behavior - panel.set_level(PanelLevel::MainMenu.value() + 1); - - panel.set_collection_behavior( - CollectionBehavior::new() - .move_to_active_space() - .full_screen_auxiliary() - .value(), - ); - - panel.set_style_mask(StyleMask::empty().nonactivating_panel().value()); - - // Set up event handler to hide panel when it loses focus - let event_handler = OpenUsagePanelEventHandler::new(); - - let handle = app_handle.clone(); - event_handler.window_did_resign_key(move |_notification| { - if let Ok(panel) = handle.get_webview_panel("main") { - panel.hide(); - } - }); - - panel.set_event_handler(Some(event_handler.as_ref())); - - Ok(()) -} - -pub fn position_panel_at_tray_icon( - app_handle: &tauri::AppHandle, +/// Compute the desired logical top-left of the panel given the tray icon rect. +/// Returns `(panel_x, panel_y, primary_logical_height)` or `None` if geometry +/// can't be resolved. The math is platform-independent; only the final apply +/// step differs per OS (macOS uses flipped/bottom-left coordinates). +fn compute_panel_top_left( + app_handle: &AppHandle, icon_position: Position, icon_size: Size, -) { - let window = app_handle.get_webview_window("main").unwrap(); +) -> Option<(f64, f64, f64)> { + let window = app_handle.get_webview_window("main")?; let (icon_phys_x, icon_phys_y) = match &icon_position { Position::Physical(pos) => (pos.x as f64, pos.y as f64), @@ -204,7 +59,7 @@ pub fn position_panel_at_tray_icon( Size::Logical(s) => (s.width, s.height), }; - let monitors = window.available_monitors().expect("failed to get monitors"); + let monitors = window.available_monitors().ok()?; let primary_logical_h = window .primary_monitor() .ok() @@ -236,10 +91,7 @@ pub fn position_panel_at_tray_icon( icon_center_x, icon_center_y ); - match window.primary_monitor() { - Ok(Some(m)) => m, - _ => return, - } + window.primary_monitor().ok().flatten()? } }; @@ -274,5 +126,246 @@ pub fn position_panel_at_tray_icon( let nudge_up: f64 = 6.0; let panel_y = icon_logical_y + icon_logical_h - nudge_up; - set_panel_top_left_immediately(&window, app_handle, panel_x, panel_y, primary_logical_h); + Some((panel_x, panel_y, primary_logical_h)) +} + +/// Position the panel directly beneath the tray icon (best-effort). +/// On Wayland the compositor may ignore the requested position; the panel is +/// still shown, just not pinned to the tray icon. +pub fn position_panel_at_tray_icon( + app_handle: &AppHandle, + icon_position: Position, + icon_size: Size, +) { + let Some((panel_x, panel_y, primary_logical_h)) = + compute_panel_top_left(app_handle, icon_position, icon_size) + else { + return; + }; + apply_panel_position(app_handle, panel_x, panel_y, primary_logical_h); +} + +// =========================================================================== +// macOS: floating NSPanel pinned under the menubar tray icon. +// =========================================================================== +#[cfg(target_os = "macos")] +mod platform { + use super::*; + + 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]; + } + + pub(super) fn apply_panel_position( + app_handle: &AppHandle, + panel_x: f64, + panel_y: f64, + primary_logical_h: f64, + ) { + let Some(window) = app_handle.get_webview_window("main") else { + return; + }; + let Ok(panel_handle) = app_handle.get_webview_panel("main") else { + return; + }; + + // macOS uses a bottom-left origin, so flip the y coordinate. + 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); + } + 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"); + } + } + + /// Get existing panel or initialize it if needed. + 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) = 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 + } + } + } + } + } + }; + } + + // Define our panel class and event handler together + tauri_panel! { + panel!(OpenUsagePanel { + config: { + can_become_key_window: true, + is_floating_panel: true + } + }) + + panel_event!(OpenUsagePanelEventHandler { + window_did_resign_key(notification: &NSNotification) -> () + }) + } + + pub fn init(app_handle: &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::()?; + + // 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); + + // Configure panel behavior + panel.set_level(PanelLevel::MainMenu.value() + 1); + + panel.set_collection_behavior( + CollectionBehavior::new() + .move_to_active_space() + .full_screen_auxiliary() + .value(), + ); + + panel.set_style_mask(StyleMask::empty().nonactivating_panel().value()); + + // Set up event handler to hide panel when it loses focus + let event_handler = OpenUsagePanelEventHandler::new(); + + let handle = app_handle.clone(); + event_handler.window_did_resign_key(move |_notification| { + if let Ok(panel) = handle.get_webview_panel("main") { + panel.hide(); + } + }); + + panel.set_event_handler(Some(event_handler.as_ref())); + + Ok(()) + } + + /// 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); + } + } + + /// Toggle panel visibility. If visible, hide it. If hidden, show it. + 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(); + position_panel_from_tray(app_handle); + } + } + + pub fn hide_panel(app_handle: &AppHandle) { + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.hide(); + } + } } + +// =========================================================================== +// Linux / other: plain always-on-top borderless window. Hidden on focus loss +// (wired up in lib.rs setup). Positioning under the tray is best-effort. +// =========================================================================== +#[cfg(not(target_os = "macos"))] +mod platform { + use super::*; + + pub(super) fn apply_panel_position( + app_handle: &AppHandle, + panel_x: f64, + panel_y: f64, + _primary_logical_h: f64, + ) { + let Some(window) = app_handle.get_webview_window("main") else { + return; + }; + if let Err(e) = window.set_position(tauri::LogicalPosition::new(panel_x, panel_y)) { + log::debug!("apply_panel_position: set_position failed (best-effort): {}", e); + } + } + + /// No NSPanel on non-macOS; the regular window is configured via tauri.conf.json. + pub fn init(_app_handle: &AppHandle) -> tauri::Result<()> { + Ok(()) + } + + /// Show the window as a floating panel, positioned under the tray icon. + pub fn show_panel(app_handle: &AppHandle) { + let Some(window) = app_handle.get_webview_window("main") else { + return; + }; + let _ = window.show(); + let _ = window.set_always_on_top(true); + let _ = window.set_focus(); + position_panel_from_tray(app_handle); + } + + /// Toggle window visibility. + pub fn toggle_panel(app_handle: &AppHandle) { + let Some(window) = app_handle.get_webview_window("main") 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"); + show_panel(app_handle); + } + } + + pub fn hide_panel(app_handle: &AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.hide(); + } + } +} + +use platform::apply_panel_position; +pub use platform::{hide_panel, init, show_panel, toggle_panel}; diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index be532de3..60486822 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -157,6 +157,7 @@ fn current_macos_keychain_account() -> String { current_macos_keychain_account_from_user_env(read_env_from_process("USER")) } +#[cfg(any(target_os = "macos", test))] fn keychain_find_generic_password_args(service: &str) -> Vec { vec![ OsString::from("find-generic-password"), @@ -166,6 +167,7 @@ fn keychain_find_generic_password_args(service: &str) -> Vec { ] } +#[cfg(any(target_os = "macos", test))] fn keychain_find_generic_password_args_for_account(service: &str, account: &str) -> Vec { vec![ OsString::from("find-generic-password"), @@ -177,6 +179,7 @@ fn keychain_find_generic_password_args_for_account(service: &str, account: &str) ] } +#[cfg(any(target_os = "macos", test))] fn keychain_add_generic_password_args(service: &str, value: &str) -> Vec { vec![ OsString::from("add-generic-password"), @@ -188,6 +191,7 @@ fn keychain_add_generic_password_args(service: &str, value: &str) -> Vec, existing_path: Option<&OsStr>) entries.push(nvm_bin); } entries.push(home.join(".local/bin")); + // Common Linux tool locations (asdf shims, cargo, rustup, etc.). + entries.push(home.join(".cargo/bin")); + entries.push(home.join(".asdf/shims")); } entries.extend( - ["/opt/homebrew/bin", "/usr/local/bin"] + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] .into_iter() .map(PathBuf::from), ); @@ -2375,6 +2382,162 @@ pub fn patch_ccusage_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { ) } +/// Look up a secret via the freedesktop Secret Service (libsecret `secret-tool`). +#[cfg(target_os = "linux")] +fn secret_tool_lookup(attrs: &[(&str, &str)]) -> Result { + let mut cmd = std::process::Command::new("secret-tool"); + cmd.arg("lookup"); + for (key, value) in attrs { + cmd.arg(key).arg(value); + } + let output = cmd + .output() + .map_err(|e| format!("secret-tool not available (install libsecret): {}", e))?; + if !output.status.success() { + return Err("keychain item not found".to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout) + .trim_end_matches('\n') + .to_string()) +} + +/// Store a secret via the freedesktop Secret Service (libsecret `secret-tool`). +#[cfg(target_os = "linux")] +fn secret_tool_store(label: &str, value: &str, attrs: &[(&str, &str)]) -> Result<(), String> { + use std::io::Write; + use std::process::Stdio; + let mut cmd = std::process::Command::new("secret-tool"); + cmd.arg("store").arg("--label").arg(label); + for (key, attr_value) in attrs { + cmd.arg(key).arg(attr_value); + } + cmd.stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|e| format!("secret-tool not available (install libsecret): {}", e))?; + child + .stdin + .take() + .ok_or_else(|| "secret-tool stdin unavailable".to_string())? + .write_all(value.as_bytes()) + .map_err(|e| format!("secret-tool write failed: {}", e))?; + let output = child + .wait_with_output() + .map_err(|e| format!("secret-tool store failed: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "keychain write failed: {}", + stderr.lines().next().unwrap_or("").trim() + )); + } + Ok(()) +} + +/// Read a generic password from the platform credential store. +/// macOS: Keychain via `security`. Linux: Secret Service via `secret-tool`. +fn keychain_op_read(service: &str, account: Option<&str>) -> Result { + #[cfg(target_os = "macos")] + { + let args = match account { + Some(a) => keychain_find_generic_password_args_for_account(service, a), + None => keychain_find_generic_password_args(service), + }; + let output = std::process::Command::new("security") + .args(args) + .output() + .map_err(|e| format!("keychain read failed: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "keychain item not found: {}", + stderr.lines().next().unwrap_or("").trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + #[cfg(target_os = "linux")] + { + match account { + Some(a) => secret_tool_lookup(&[("service", service), ("account", a)]), + None => secret_tool_lookup(&[("service", service)]), + } + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (service, account); + Err("keychain API is not supported on this platform".to_string()) + } +} + +/// Write a generic password to the platform credential store. +fn keychain_op_write(service: &str, account: Option<&str>, value: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + // When no account is supplied, reuse the existing item's account (if any) + // so we update in place rather than create a duplicate entry. + let resolved_account = match account { + Some(a) => Some(a.to_string()), + None => { + let mut found: Option = None; + if let Ok(output) = std::process::Command::new("security") + .args(["find-generic-password", "-s", service]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some(start) = line.find("\"acct\"=\"") { + let rest = &line[start + 14..]; + if let Some(end) = rest.find('"') { + found = Some(rest[..end].to_string()); + break; + } + } + } + } + } + found + } + }; + + let output = match resolved_account.as_deref() { + Some(acct) => std::process::Command::new("security") + .args(keychain_add_generic_password_args_for_account( + service, acct, value, + )) + .output(), + None => std::process::Command::new("security") + .args(keychain_add_generic_password_args(service, value)) + .output(), + } + .map_err(|e| format!("keychain write failed: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "keychain write failed: {}", + stderr.lines().next().unwrap_or("").trim() + )); + } + Ok(()) + } + #[cfg(target_os = "linux")] + { + match account { + Some(a) => secret_tool_store(service, value, &[("service", service), ("account", a)]), + None => secret_tool_store(service, value, &[("service", service)]), + } + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + let _ = (service, account, value); + Err("keychain API is not supported on this platform".to_string()) + } +} + fn inject_keychain<'js>( ctx: &Ctx<'js>, host: &Object<'js>, @@ -2388,44 +2551,22 @@ fn inject_keychain<'js>( Function::new( ctx.clone(), move |ctx_inner: Ctx<'_>, service: String| -> rquickjs::Result { - if !cfg!(target_os = "macos") { - return Err(Exception::throw_message( - &ctx_inner, - "keychain API is only supported on macOS", - )); - } log::info!("[plugin:{}] keychain read: service={}", pid_read, service); - let output = std::process::Command::new("security") - .args(keychain_find_generic_password_args(&service)) - .output() - .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("keychain read failed: {}", e), - ) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let first_line = stderr.lines().next().unwrap_or("").trim(); - log::warn!( - "[plugin:{}] keychain read miss: service={}, error={}", - pid_read, - service, - first_line - ); - return Err(Exception::throw_message( - &ctx_inner, - &format!("keychain item not found: {}", first_line), - )); + match keychain_op_read(&service, None) { + Ok(secret) => { + log::info!("[plugin:{}] keychain read hit: service={}", pid_read, service); + Ok(secret) + } + Err(e) => { + log::warn!( + "[plugin:{}] keychain read miss: service={}, error={}", + pid_read, + service, + e + ); + Err(Exception::throw_message(&ctx_inner, &e)) + } } - - log::info!( - "[plugin:{}] keychain read hit: service={}", - pid_read, - service - ); - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) }, )?, )?; @@ -2436,14 +2577,7 @@ fn inject_keychain<'js>( Function::new( ctx.clone(), move |ctx_inner: Ctx<'_>, service: String| -> rquickjs::Result { - if !cfg!(target_os = "macos") { - return Err(Exception::throw_message( - &ctx_inner, - "keychain API is only supported on macOS", - )); - } let account = current_macos_keychain_account(); - let args = keychain_find_generic_password_args_for_account(&service, &account); let redacted_account = redact_value(&account); log::info!( "[plugin:{}] keychain read: service={}, account={}", @@ -2451,39 +2585,27 @@ fn inject_keychain<'js>( service, redacted_account ); - let output = std::process::Command::new("security") - .args(&args) - .output() - .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("keychain read failed: {}", e), - ) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let first_line = stderr.lines().next().unwrap_or("").trim(); - log::warn!( - "[plugin:{}] keychain read miss: service={}, account={}, error={}", - pid_read_current_user, - service, - redacted_account, - first_line - ); - return Err(Exception::throw_message( - &ctx_inner, - &format!("keychain item not found: {}", first_line), - )); + match keychain_op_read(&service, Some(&account)) { + Ok(secret) => { + log::info!( + "[plugin:{}] keychain read hit: service={}, account={}", + pid_read_current_user, + service, + redacted_account + ); + Ok(secret) + } + Err(e) => { + log::warn!( + "[plugin:{}] keychain read miss: service={}, account={}, error={}", + pid_read_current_user, + service, + redacted_account, + e + ); + Err(Exception::throw_message(&ctx_inner, &e)) + } } - - log::info!( - "[plugin:{}] keychain read hit: service={}, account={}", - pid_read_current_user, - service, - redacted_account - ); - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) }, )?, )?; @@ -2494,70 +2616,26 @@ fn inject_keychain<'js>( Function::new( ctx.clone(), move |ctx_inner: Ctx<'_>, service: String, value: String| -> rquickjs::Result<()> { - if !cfg!(target_os = "macos") { - return Err(Exception::throw_message( - &ctx_inner, - "keychain API is only supported on macOS", - )); - } log::info!("[plugin:{}] keychain write: service={}", pid_write, service); - - let mut account_arg: Option = None; - let find_output = std::process::Command::new("security") - .args(["find-generic-password", "-s", &service]) - .output(); - - if let Ok(output) = find_output { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if let Some(start) = line.find("\"acct\"=\"") { - let rest = &line[start + 14..]; - if let Some(end) = rest.find('"') { - account_arg = Some(rest[..end].to_string()); - break; - } - } - } + match keychain_op_write(&service, None, &value) { + Ok(()) => { + log::info!( + "[plugin:{}] keychain write succeeded: service={}", + pid_write, + service + ); + Ok(()) + } + Err(e) => { + log::warn!( + "[plugin:{}] keychain write failed: service={}, error={}", + pid_write, + service, + e + ); + Err(Exception::throw_message(&ctx_inner, &e)) } } - - let output = if let Some(ref acct) = account_arg { - std::process::Command::new("security") - .args(keychain_add_generic_password_args_for_account( - &service, acct, &value, - )) - .output() - } else { - std::process::Command::new("security") - .args(keychain_add_generic_password_args(&service, &value)) - .output() - } - .map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("keychain write failed: {}", e)) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let first_line = stderr.lines().next().unwrap_or("").trim(); - log::warn!( - "[plugin:{}] keychain write failed: service={}, error={}", - pid_write, - service, - first_line - ); - return Err(Exception::throw_message( - &ctx_inner, - &format!("keychain write failed: {}", first_line), - )); - } - - log::info!( - "[plugin:{}] keychain write succeeded: service={}", - pid_write, - service - ); - Ok(()) }, )?, )?; @@ -2568,15 +2646,7 @@ fn inject_keychain<'js>( Function::new( ctx.clone(), move |ctx_inner: Ctx<'_>, service: String, value: String| -> rquickjs::Result<()> { - if !cfg!(target_os = "macos") { - return Err(Exception::throw_message( - &ctx_inner, - "keychain API is only supported on macOS", - )); - } let account = current_macos_keychain_account(); - let args = - keychain_add_generic_password_args_for_account(&service, &account, &value); let redacted_account = redact_value(&account); log::info!( "[plugin:{}] keychain write: service={}, account={}", @@ -2584,39 +2654,27 @@ fn inject_keychain<'js>( service, redacted_account ); - let output = std::process::Command::new("security") - .args(&args) - .output() - .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("keychain write failed: {}", e), - ) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let first_line = stderr.lines().next().unwrap_or("").trim(); - log::warn!( - "[plugin:{}] keychain write failed: service={}, account={}, error={}", - pid_write_current_user, - service, - redacted_account, - first_line - ); - return Err(Exception::throw_message( - &ctx_inner, - &format!("keychain write failed: {}", first_line), - )); + match keychain_op_write(&service, Some(&account), &value) { + Ok(()) => { + log::info!( + "[plugin:{}] keychain write succeeded: service={}, account={}", + pid_write_current_user, + service, + redacted_account + ); + Ok(()) + } + Err(e) => { + log::warn!( + "[plugin:{}] keychain write failed: service={}, account={}, error={}", + pid_write_current_user, + service, + redacted_account, + e + ); + Err(Exception::throw_message(&ctx_inner, &e)) + } } - - log::info!( - "[plugin:{}] keychain write succeeded: service={}, account={}", - pid_write_current_user, - service, - redacted_account - ); - Ok(()) }, )?, )?; @@ -3809,6 +3867,8 @@ mod tests { home.join(".bun/bin"), home.join(".nvm/current/bin"), home.join(".local/bin"), + home.join(".cargo/bin"), + home.join(".asdf/shims"), std::path::PathBuf::from("/opt/homebrew/bin"), std::path::PathBuf::from("/usr/local/bin"), std::path::PathBuf::from("/usr/bin"), @@ -3833,6 +3893,7 @@ mod tests { vec![ std::path::PathBuf::from("/opt/homebrew/bin"), std::path::PathBuf::from("/usr/local/bin"), + std::path::PathBuf::from("/usr/bin"), std::path::PathBuf::from("/custom/bin"), ] ); @@ -3848,6 +3909,7 @@ mod tests { vec![ std::path::PathBuf::from("/opt/homebrew/bin"), std::path::PathBuf::from("/usr/local/bin"), + std::path::PathBuf::from("/usr/bin"), ] ); } @@ -3872,6 +3934,8 @@ mod tests { home.join(".bun/bin"), home.join(".nvm/current/bin"), home.join(".local/bin"), + home.join(".cargo/bin"), + home.join(".asdf/shims"), std::path::PathBuf::from("/opt/homebrew/bin"), std::path::PathBuf::from("/usr/local/bin"), std::path::PathBuf::from("/usr/bin"), @@ -4170,6 +4234,9 @@ esac let mut permissions = script.metadata().expect("script metadata").permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&script_path, permissions).expect("make script executable"); + // Close the file handle before exec: Linux returns ETXTBSY when running + // a file that is still open for writing. + drop(script); let opts = CcusageQueryOpts { provider: Some("codex".to_string()), @@ -4288,6 +4355,9 @@ wait let mut permissions = script.metadata().expect("script metadata").permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&script_path, permissions).expect("make script executable"); + // Close the file handle before exec: Linux returns ETXTBSY when running + // a file that is still open for writing. + drop(script); let opts = CcusageQueryOpts::default(); let start = Instant::now(); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 545f29b0..a90343d3 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"; @@ -182,25 +181,13 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { .on_tray_icon_event(|tray, event| { let app_handle = tray.app_handle(); - if let TrayIconEvent::Click { - button_state, rect, .. - } = event - { + if let TrayIconEvent::Click { 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 shows/hides and positions under the tray icon. + // Note: on some Linux desktops (e.g. GNOME without an + // AppIndicator extension) left-click does not emit this + // event — use the "Show Stats" menu item or global shortcut. + toggle_panel(app_handle); } } })