From 8fd2f7757267083d36c26f81f1bba2915094ec7a Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 14:57:17 +0400 Subject: [PATCH 01/10] feat(panel): implement Escape key functionality to hide panel - Added a new command to hide the panel in the Tauri application. - Implemented a keydown event listener to hide the panel when the Escape key is pressed, unless the About dialog is open. - Enhanced user experience by allowing quick dismissal of the panel without navigating through the UI. --- src-tauri/src/lib.rs | 9 +++++++++ src/App.tsx | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 51e602bf..3df6cef7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,6 +63,14 @@ fn init_panel(app_handle: tauri::AppHandle) { panel::init(&app_handle).expect("Failed to initialize panel"); } +#[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(); + } +} + #[tauri::command] async fn start_probe_batch( app_handle: tauri::AppHandle, @@ -221,6 +229,7 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ init_panel, + hide_panel, start_probe_batch, list_plugins ]) diff --git a/src/App.tsx b/src/App.tsx index 5c06e74c..a2cb3f2c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -229,6 +229,20 @@ function App() { invoke("init_panel").catch(console.error); }, []); + // Hide panel on Escape key (unless about dialog is open - it handles its own Escape) + useEffect(() => { + if (!isTauri()) return + if (showAbout) return // Let dialog handle its own Escape + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + invoke("hide_panel") + } + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [showAbout]) + // Listen for tray menu events useEffect(() => { if (!isTauri()) return From 8a5f99c5f2693f4c3666dfd57e17497e1406f3bd Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 15:42:36 +0400 Subject: [PATCH 02/10] feat(update-button): add rotating border beam animation to update button (#58) Adds a visually distinctive rotating border animation to the "Restart to update" button when an update is ready. The beam travels around the button's edge using CSS mask-composite, making it more noticeable without adding dependencies. Also fixes dev build by running bundle:plugins before dev server starts. Co-authored-by: Claude Haiku 4.5 --- src-tauri/tauri.conf.json | 2 +- src/components/panel-footer.tsx | 1 + src/index.css | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 75a8a239..d6545ea8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,7 +4,7 @@ "version": "0.1.2", "identifier": "com.sunstory.openusage", "build": { - "beforeDevCommand": "bun run dev", + "beforeDevCommand": "bun run bundle:plugins && bun run dev", "devUrl": "http://localhost:1420", "beforeBuildCommand": "bun run bundle:plugins && bun run build", "frontendDist": "../dist" diff --git a/src/components/panel-footer.tsx b/src/components/panel-footer.tsx index 34e1907f..9f7facc9 100644 --- a/src/components/panel-footer.tsx +++ b/src/components/panel-footer.tsx @@ -39,6 +39,7 @@ function VersionDisplay({ + ); + })} + + +

Auto Update

diff --git a/vite.config.ts b/vite.config.ts index fd058f33..12c85486 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,8 @@ export default defineConfig(async () => ({ "src-tauri/**", "src-tauri/resources/**", "src-tauri/icons/**", + // Test-only helpers (not production code) + "plugins/test-helpers.js", ], reporter: ["text", "html", "lcov"], thresholds: { From 147ca9251e8f43aad4f35b332e2c1e2d749df571 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 16:54:08 +0400 Subject: [PATCH 05/10] fix(plugin): minify keychain JSON to prevent hex-encoding on read (#61) macOS security command hex-encodes credential values containing newlines. Pretty-printed JSON caused the keychain to store hex instead of readable JSON, breaking Claude Code's credential validation on read. Co-authored-by: Claude Haiku 4.5 --- plugins/claude/plugin.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index 97c32c8c..612617b5 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -156,7 +156,9 @@ } function saveCredentials(ctx, source, fullData) { - const text = JSON.stringify(fullData, null, 2) + // MUST use minified JSON - macOS `security -w` hex-encodes values with newlines, + // which Claude Code can't read back, causing it to invalidate the session. + const text = JSON.stringify(fullData) if (source === "file") { try { ctx.host.fs.writeText(CRED_FILE, text) From 430f1b92dce4aad99294f425bf4b0e454b2fc499 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 17:16:02 +0400 Subject: [PATCH 06/10] fix(build): exclude test files from production tsc check (#62) * fix(build): exclude test files from production tsc check The build command (tsc && vite build) type-checked test files alongside source code, so any test-only type error would break production builds. Exclude *.test.ts(x) from tsconfig.json since vitest handles test type-checking independently. Closes #55 Co-Authored-By: Claude Opus 4.5 * fix(build): also exclude src/test/** from production tsc check The test setup file (src/test/setup.ts) imports vitest and @testing-library/*, so it needs to be excluded alongside test files. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 33514fa0..506bac7a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,5 +27,6 @@ } }, "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test/**"], "references": [{ "path": "./tsconfig.node.json" }] } From 8c0254c019cab3ced495cec6dee60be017c59990 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 17:45:06 +0400 Subject: [PATCH 07/10] ui: default usage display to Left (#63) * refactor(tests): update App tests for display mode and error handling - Changed the default display mode from "used" to "left" in the App tests. - Added tests to log errors when saving and loading display modes fail. - Implemented a new test to verify panel hiding functionality on Escape key press in Tauri. - Enhanced the ProviderCard component to utilize brand colors and improved badge styling. - Updated Skeleton components for consistent height adjustments. * refactor(ui): drop brand-filled badge styling Revert badge rendering back to outline-only styling. Remove ProviderCard brandColor plumbing and delete the unused badge style helper. Co-authored-by: Cursor * docs(plugin-api): update progress builder and schema for v2 Document ctx.line.progress({ used, limit, format, resetsAt }) and ctx.util.toIso. Update MetricLine progress schema and examples to match runtime behavior. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- docs/plugins/api.md | 53 +++++++++++++++++----- docs/plugins/schema.md | 48 ++++++++++++++------ src/App.test.tsx | 65 +++++++++++++++++++++++++-- src/components/provider-card.tsx | 6 ++- src/components/skeleton-lines.tsx | 8 ++-- src/lib/settings.ts | 4 +- src/lib/tray-primary-progress.test.ts | 1 + src/lib/tray-primary-progress.ts | 4 +- 8 files changed, 154 insertions(+), 35 deletions(-) diff --git a/docs/plugins/api.md b/docs/plugins/api.md index d7f34a83..55c9b7c9 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -305,25 +305,34 @@ Creates a progress bar line. ```typescript ctx.line.progress({ label: string, // Required: label shown on the left - value: number, // Required: current value - max: number, // Required: maximum value - unit?: "percent" | "dollars", // Optional: format as percentage or dollars + used: number, // Required: amount used (>= 0) + limit: number, // Required: limit (> 0) + format: { // Required: formatting rules + kind: "percent" | "dollars" | "count", + suffix?: string // Required when kind="count" (e.g. "credits") + }, + resetsAt?: string | null, // Optional: ISO timestamp for when usage resets color?: string, // Optional: hex color for progress bar - subtitle?: string // Optional: smaller text below the line }): MetricLine ``` +Notes: + +- `used` may exceed `limit` (overages). +- For `format.kind: "percent"`, `limit` must be `100`. +- Prefer setting `resetsAt` (via `ctx.util.toIso(...)`) instead of putting reset info in other lines. + **Example:** ```javascript -ctx.line.progress({ label: "Usage", value: 42, max: 100, unit: "percent" }) -ctx.line.progress({ label: "Spend", value: 12.34, max: 100, unit: "dollars" }) +ctx.line.progress({ label: "Usage", used: 42, limit: 100, format: { kind: "percent" } }) +ctx.line.progress({ label: "Spend", used: 12.34, limit: 100, format: { kind: "dollars" } }) ctx.line.progress({ label: "Session", - value: 75, - max: 100, - unit: "percent", - subtitle: "Resets in 6d 20h" + used: 75, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso("2026-02-01T00:00:00Z"), }) ``` @@ -388,6 +397,30 @@ Formats Unix milliseconds as short date. ctx.fmt.date(1704067200000) // "Jan 1" ``` +## Utilities + +### `ctx.util.toIso(value)` + +Normalizes a timestamp into an ISO string (or returns `null` if the input can't be parsed). + +Accepts common inputs like: + +- ISO strings (with or without timezone; timezone-less is treated as UTC) +- Unix seconds / milliseconds (number or numeric string) +- `Date` objects + +**Example:** + +```javascript +ctx.line.progress({ + label: "Weekly", + used: 24, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(data.resets_at), +}) +``` + ## See Also - [Plugin Schema](./schema.md) - Plugin structure, manifest format, and output schema diff --git a/docs/plugins/schema.md b/docs/plugins/schema.md index c2171f96..b1f00c4e 100644 --- a/docs/plugins/schema.md +++ b/docs/plugins/schema.md @@ -137,13 +137,24 @@ globalThis.__openusage_plugin = { ```typescript type MetricLine = | { type: "text"; label: string; value: string; color?: string; subtitle?: string } - | { type: "progress"; label: string; value: number; max: number; unit?: "percent" | "dollars"; color?: string; subtitle?: string } + | { + type: "progress"; + label: string; + used: number; + limit: number; + format: + | { kind: "percent" } + | { kind: "dollars" } + | { kind: "count"; suffix: string }; + resetsAt?: string; // ISO timestamp + color?: string; + } | { type: "badge"; label: string; text: string; color?: string; subtitle?: string } ``` - `color`: optional hex string (e.g. `#22c55e`) -- `unit`: `"percent"` shows `X%`, `"dollars"` shows `$X.XX` - `subtitle`: optional text displayed below the line in smaller muted text +- `resetsAt`: optional ISO timestamp (UI shows "Resets in ..." automatically) ### Text Line @@ -159,15 +170,20 @@ ctx.line.text({ label: "Status", value: "Active", color: "#22c55e", subtitle: "S Shows a progress bar with optional formatting. ```javascript -ctx.line.progress({ label: "Usage", value: 42, max: 100, unit: "percent" }) -// Renders: Usage [████████░░░░░░░░░░░░] 42% - -ctx.line.progress({ label: "Spend", value: 12.34, max: 100, unit: "dollars" }) -// Renders: Spend [█░░░░░░░░░░░░░░░░░░░] $12.34 - -ctx.line.progress({ label: "Session", value: 75, max: 100, unit: "percent", subtitle: "Resets in 6d 20h" }) -// Renders: Session [███████████████░░░░░] 75% -// Resets in 6d 20h +ctx.line.progress({ label: "Usage", used: 42, limit: 100, format: { kind: "percent" } }) +// Renders (depending on user settings): "42%" or "58% left" + +ctx.line.progress({ label: "Spend", used: 12.34, limit: 100, format: { kind: "dollars" } }) +// Renders: "$12.34" or "$87.66 left" + +ctx.line.progress({ + label: "Session", + used: 75, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso("2026-02-01T00:00:00Z"), +}) +// UI will show: "Resets in …" ``` ### Badge Line @@ -189,7 +205,7 @@ ctx.line.badge({ label: "Status", text: "Connected", color: "#22c55e", subtitle: | Promise never resolves | Error badge (timeout) | | Invalid line type | Error badge | | Missing `lines` array | Error badge | -| Non-finite progress values | Coerced to `value: -1, max: 0` (UI shows N/A) | +| Invalid progress values | Error badge (line-specific validation error) | Prefer throwing short, actionable strings (not `Error` objects). @@ -247,7 +263,13 @@ A complete, working plugin that fetches data and displays all three line types. return { lines: [ ctx.line.badge({ label: "Status", text: "Connected", color: "#22c55e" }), - ctx.line.progress({ label: "Usage", value: 42, max: 100, unit: "percent", subtitle: "Resets in 2d 5h" }), + ctx.line.progress({ + label: "Usage", + used: 42, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso("2026-02-01T00:00:00Z"), + }), ctx.line.text({ label: "Fetched at", value: ctx.nowIso }), ], } diff --git a/src/App.test.tsx b/src/App.test.tsx index e06bcbcc..85d04740 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -183,7 +183,7 @@ describe("App", () => { state.saveAutoUpdateIntervalMock.mockResolvedValue(undefined) state.loadThemeModeMock.mockResolvedValue("system") state.saveThemeModeMock.mockResolvedValue(undefined) - state.loadDisplayModeMock.mockResolvedValue("used") + state.loadDisplayModeMock.mockResolvedValue("left") state.saveDisplayModeMock.mockResolvedValue(undefined) Object.defineProperty(HTMLElement.prototype, "scrollHeight", { configurable: true, @@ -321,8 +321,22 @@ describe("App", () => { const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) await userEvent.click(settingsButtons[0]) - await userEvent.click(await screen.findByRole("radio", { name: "Left" })) - expect(state.saveDisplayModeMock).toHaveBeenCalledWith("left") + await userEvent.click(await screen.findByRole("radio", { name: "Used" })) + expect(state.saveDisplayModeMock).toHaveBeenCalledWith("used") + }) + + it("logs when saving display mode fails", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + state.saveDisplayModeMock.mockRejectedValueOnce(new Error("save display mode")) + + render() + const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) + await userEvent.click(settingsButtons[0]) + + await userEvent.click(await screen.findByRole("radio", { name: "Used" })) + await waitFor(() => expect(errorSpy).toHaveBeenCalled()) + + errorSpy.mockRestore() }) it("shows provider not found when tray navigates to unknown view", async () => { @@ -337,6 +351,17 @@ describe("App", () => { await screen.findByText("Provider not found") }) + it("hides the panel on Escape when running in Tauri", async () => { + state.isTauriMock.mockReturnValue(true) + render() + + await waitFor(() => expect(state.invokeMock).toHaveBeenCalledWith("list_plugins")) + + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) + + await waitFor(() => expect(state.invokeMock).toHaveBeenCalledWith("hide_panel")) + }) + it("toggles plugins in settings", async () => { render() const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) @@ -367,6 +392,17 @@ describe("App", () => { errorSpy.mockRestore() }) + it("logs when loading display mode fails", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + state.loadDisplayModeMock.mockRejectedValueOnce(new Error("load display mode")) + + render() + await waitFor(() => expect(state.invokeMock).toHaveBeenCalledWith("list_plugins")) + await waitFor(() => expect(errorSpy).toHaveBeenCalled()) + + errorSpy.mockRestore() + }) + it("logs when saving theme mode fails", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) state.saveThemeModeMock.mockRejectedValueOnce(new Error("save theme")) @@ -455,6 +491,29 @@ describe("App", () => { await waitFor(() => expect(state.setSizeMock).toHaveBeenCalled()) }) + it("resizes again via ResizeObserver callback", async () => { + const OriginalResizeObserver = globalThis.ResizeObserver + const observeSpy = vi.fn() + globalThis.ResizeObserver = class ResizeObserverImmediate { + private cb: ResizeObserverCallback + constructor(cb: ResizeObserverCallback) { + this.cb = cb + } + observe() { + observeSpy() + this.cb([], this as unknown as ResizeObserver) + } + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver + + render() + await waitFor(() => expect(observeSpy).toHaveBeenCalled()) + await waitFor(() => expect(state.setSizeMock).toHaveBeenCalled()) + + globalThis.ResizeObserver = OriginalResizeObserver + }) + it("logs resize failures", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) state.setSizeMock.mockRejectedValueOnce(new Error("size fail")) diff --git a/src/components/provider-card.tsx b/src/components/provider-card.tsx index b6ad10d4..e7372372 100644 --- a/src/components/provider-card.tsx +++ b/src/components/provider-card.tsx @@ -174,7 +174,11 @@ export function ProviderCard({ )} {plan && ( - + {plan} )} diff --git a/src/components/skeleton-lines.tsx b/src/components/skeleton-lines.tsx index 4c20988d..64464911 100644 --- a/src/components/skeleton-lines.tsx +++ b/src/components/skeleton-lines.tsx @@ -5,7 +5,7 @@ function SkeletonText({ label }: { label: string }) { return (

{label} - +
) } @@ -14,7 +14,7 @@ function SkeletonBadge({ label }: { label: string }) { return (
{label} - +
) } @@ -25,8 +25,8 @@ function SkeletonProgress({ label }: { label: string }) {
{label}
- - + +
) diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 3912f30f..a102c851 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -24,7 +24,7 @@ const DISPLAY_MODE_KEY = "displayMode"; export const DEFAULT_AUTO_UPDATE_INTERVAL: AutoUpdateIntervalMinutes = 15; export const DEFAULT_THEME_MODE: ThemeMode = "system"; -export const DEFAULT_DISPLAY_MODE: DisplayMode = "used"; +export const DEFAULT_DISPLAY_MODE: DisplayMode = "left"; const AUTO_UPDATE_INTERVALS: AutoUpdateIntervalMinutes[] = [5, 15, 30, 60]; const THEME_MODES: ThemeMode[] = ["system", "light", "dark"]; @@ -43,8 +43,8 @@ export const THEME_OPTIONS: { value: ThemeMode; label: string }[] = })); export const DISPLAY_MODE_OPTIONS: { value: DisplayMode; label: string }[] = [ - { value: "used", label: "Used" }, { value: "left", label: "Left" }, + { value: "used", label: "Used" }, ]; const store = new LazyStore(SETTINGS_STORE_PATH); diff --git a/src/lib/tray-primary-progress.test.ts b/src/lib/tray-primary-progress.test.ts index 9e8978ca..ed5284d7 100644 --- a/src/lib/tray-primary-progress.test.ts +++ b/src/lib/tray-primary-progress.test.ts @@ -49,6 +49,7 @@ describe("getTrayPrimaryBars", () => { it("computes fraction from matching progress label and clamps 0..1", () => { const bars = getTrayPrimaryBars({ + displayMode: "used", pluginsMeta: [ { id: "a", diff --git a/src/lib/tray-primary-progress.ts b/src/lib/tray-primary-progress.ts index ed8c9af2..4978f051 100644 --- a/src/lib/tray-primary-progress.ts +++ b/src/lib/tray-primary-progress.ts @@ -1,6 +1,6 @@ import type { PluginMeta, PluginOutput } from "@/lib/plugin-types" import type { PluginSettings } from "@/lib/settings" -import type { DisplayMode } from "@/lib/settings" +import { DEFAULT_DISPLAY_MODE, type DisplayMode } from "@/lib/settings" import { clamp01 } from "@/lib/utils" type PluginState = { @@ -30,7 +30,7 @@ export function getTrayPrimaryBars(args: { maxBars?: number displayMode?: DisplayMode }): TrayPrimaryBar[] { - const { pluginsMeta, pluginSettings, pluginStates, maxBars = 4, displayMode = "used" } = args + const { pluginsMeta, pluginSettings, pluginStates, maxBars = 4, displayMode = DEFAULT_DISPLAY_MODE } = args if (!pluginSettings) return [] const metaById = new Map(pluginsMeta.map((p) => [p.id, p])) From 03757a6162fadda57ca413345755e111f5dac671 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 18:59:54 +0400 Subject: [PATCH 08/10] feat(logging): Add debug logging with tray menu level selector (#64) * feat(logging): Add debug logging with tray menu level selector - File logging to ~/Library/Logs with 10MB max size - Tray menu "Debug Level" submenu (Error/Warn/Info/Debug/Trace), persisted - HTTP request/response logging with PII redaction (first4...last4) - Forward frontend console.error/warn to log file Co-authored-by: Cursor * feat(plugins): Add diagnostic logging for auth flows - Log credential loading (source, success/failure) - Log token refresh attempts with status codes and error codes - Log usage API call results and auth failures - Helps debug "Token expired" errors reported by users Co-authored-by: Cursor * fix: Address PR review feedback - Use UTF-8 safe char iteration for redaction (prevents panic on multi-byte) - Add missing "off" case in get_stored_log_level - Log level change message before set_max_level (so it's visible) - Improve frontend logging to serialize objects properly (JSON.stringify) Co-authored-by: Cursor * feat(logging): Enhance logging capabilities in test helpers and HTTP response handling - Added trace and debug logging functions to the test helpers for improved diagnostics. - Implemented body redaction before truncation in HTTP response logging to ensure sensitive information is protected. - Updated logging to use the redacted body preview for better security and clarity in logs. --------- Co-authored-by: Cursor --- bun.lock | 3 + package.json | 1 + plugins/claude/plugin.js | 46 ++++++- plugins/codex/plugin.js | 61 +++++++-- plugins/cursor/plugin.js | 41 +++++- plugins/test-helpers.js | 3 + src-tauri/Cargo.lock | 7 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 49 ++++++- src-tauri/src/plugin_engine/host_api.rs | 165 +++++++++++++++++++++++- src-tauri/src/tray.rs | 92 ++++++++++++- src/main.tsx | 26 ++++ 12 files changed, 466 insertions(+), 29 deletions(-) diff --git a/bun.lock b/bun.lock index 2fe64b32..9e773d65 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", @@ -304,6 +305,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], + "@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.8.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw=="], + "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], diff --git a/package.json b/package.json index 5dc7a647..208470d3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index 612617b5..5a792aee 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -130,10 +130,13 @@ if (parsed) { const oauth = parsed.claudeAiOauth if (oauth && oauth.accessToken) { + ctx.host.log.info("credentials loaded from file") return { oauth, source: "file", fullData: parsed } } } + ctx.host.log.warn("credentials file exists but no valid oauth data") } catch (e) { + ctx.host.log.warn("credentials file read failed: " + String(e)) } } @@ -145,13 +148,17 @@ if (parsed) { const oauth = parsed.claudeAiOauth if (oauth && oauth.accessToken) { + ctx.host.log.info("credentials loaded from keychain") return { oauth, source: "keychain", fullData: parsed } } } + ctx.host.log.warn("keychain has data but no valid oauth") } } catch (e) { + ctx.host.log.info("keychain read failed (may not exist): " + String(e)) } + ctx.host.log.warn("no credentials found") return null } @@ -184,8 +191,12 @@ function refreshToken(ctx, creds) { const { oauth, source, fullData } = creds - if (!oauth.refreshToken) return null + if (!oauth.refreshToken) { + ctx.host.log.warn("refresh skipped: no refresh token") + return null + } + ctx.host.log.info("attempting token refresh") try { const resp = ctx.util.request({ method: "POST", @@ -204,17 +215,27 @@ let errorCode = null const body = ctx.util.tryParseJson(resp.bodyText) 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 "Token expired. Run `claude` to log in again." } - if (resp.status < 200 || resp.status >= 300) return null + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("refresh returned unexpected status: " + resp.status) + return null + } const body = ctx.util.tryParseJson(resp.bodyText) - if (!body) return null + if (!body) { + ctx.host.log.warn("refresh response not valid JSON") + return null + } const newAccessToken = body.access_token - if (!newAccessToken) return null + if (!newAccessToken) { + ctx.host.log.warn("refresh response missing access_token") + return null + } // Update oauth credentials oauth.accessToken = newAccessToken @@ -227,9 +248,11 @@ fullData.claudeAiOauth = oauth saveCredentials(ctx, source, fullData) + ctx.host.log.info("refresh succeeded, new token expires in " + (body.expires_in || "unknown") + "s") return newAccessToken } catch (e) { if (typeof e === "string") throw e + ctx.host.log.error("refresh exception: " + String(e)) return null } } @@ -252,6 +275,7 @@ function probe(ctx) { 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." } @@ -260,8 +284,13 @@ // Proactively refresh if token is expired or about to expire if (needsRefresh(ctx, creds.oauth, nowMs)) { + ctx.host.log.info("token needs refresh (expired or expiring soon)") const refreshed = refreshToken(ctx, creds) - if (refreshed) accessToken = refreshed + if (refreshed) { + accessToken = refreshed + } else { + ctx.host.log.warn("proactive refresh failed, trying with existing token") + } } let resp @@ -272,6 +301,7 @@ try { return fetchUsage(ctx, token || accessToken) } catch (e) { + ctx.host.log.error("usage request exception: " + String(e)) if (didRefresh) { throw "Usage request failed after refresh. Try again." } @@ -279,22 +309,28 @@ } }, refresh: () => { + ctx.host.log.info("usage returned 401, attempting refresh") didRefresh = true return refreshToken(ctx, creds) }, }) } catch (e) { if (typeof e === "string") throw e + ctx.host.log.error("usage request failed: " + String(e)) throw "Usage request failed. Check your connection." } 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." } if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.error("usage returned error: status=" + resp.status) throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later." } + + ctx.host.log.info("usage fetch succeeded") let data data = ctx.util.tryParseJson(resp.bodyText) diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index a271f272..e1f716ee 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -6,11 +6,21 @@ const REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000 function loadAuth(ctx) { - if (!ctx.host.fs.exists(AUTH_PATH)) return null + if (!ctx.host.fs.exists(AUTH_PATH)) { + ctx.host.log.warn("auth file not found: " + AUTH_PATH) + return null + } try { const text = ctx.host.fs.readText(AUTH_PATH) - return ctx.util.tryParseJson(text) - } catch { + const auth = ctx.util.tryParseJson(text) + if (auth) { + ctx.host.log.info("auth loaded from file") + } else { + ctx.host.log.warn("auth file exists but not valid JSON") + } + return auth + } catch (e) { + ctx.host.log.warn("auth file read failed: " + String(e)) return null } } @@ -23,8 +33,12 @@ } function refreshToken(ctx, auth) { - if (!auth.tokens || !auth.tokens.refresh_token) return null + if (!auth.tokens || !auth.tokens.refresh_token) { + ctx.host.log.warn("refresh skipped: no refresh token") + return null + } + ctx.host.log.info("attempting token refresh") try { const resp = ctx.util.request({ method: "POST", @@ -43,6 +57,7 @@ if (body) { code = body.error?.code || body.error || body.code } + ctx.host.log.error("refresh failed: status=" + resp.status + " code=" + String(code)) if (code === "refresh_token_expired") { throw "Session expired. Run `codex` to log in again." } @@ -54,12 +69,21 @@ } throw "Token expired. Run `codex` to log in again." } - if (resp.status < 200 || resp.status >= 300) return null + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("refresh returned unexpected status: " + resp.status) + return null + } const body = ctx.util.tryParseJson(resp.bodyText) - if (!body) return null + if (!body) { + ctx.host.log.warn("refresh response not valid JSON") + return null + } const newAccessToken = body.access_token - if (!newAccessToken) return null + if (!newAccessToken) { + ctx.host.log.warn("refresh response missing access_token") + return null + } auth.tokens.access_token = newAccessToken if (body.refresh_token) auth.tokens.refresh_token = body.refresh_token @@ -68,11 +92,15 @@ try { ctx.host.fs.writeText(AUTH_PATH, JSON.stringify(auth, null, 2)) - } catch {} + ctx.host.log.info("refresh succeeded, auth file updated") + } catch (e) { + ctx.host.log.warn("refresh succeeded but failed to save auth: " + String(e)) + } return newAccessToken } catch (e) { if (typeof e === "string") throw e + ctx.host.log.error("refresh exception: " + String(e)) return null } } @@ -118,6 +146,7 @@ function probe(ctx) { const auth = loadAuth(ctx) if (!auth) { + ctx.host.log.error("probe failed: not logged in") throw "Not logged in. Run `codex` to authenticate." } @@ -127,8 +156,13 @@ const accountId = auth.tokens.account_id if (needsRefresh(ctx, auth, nowMs)) { + ctx.host.log.info("token needs refresh (age > " + (REFRESH_AGE_MS / 1000 / 60 / 60 / 24) + " days)") const refreshed = refreshToken(ctx, auth) - if (refreshed) accessToken = refreshed + if (refreshed) { + accessToken = refreshed + } else { + ctx.host.log.warn("proactive refresh failed, trying with existing token") + } } let resp @@ -138,7 +172,8 @@ request: (token) => { try { return fetchUsage(ctx, token || accessToken, accountId) - } catch { + } catch (e) { + ctx.host.log.error("usage request exception: " + String(e)) if (didRefresh) { throw "Usage request failed after refresh. Try again." } @@ -146,22 +181,28 @@ } }, refresh: () => { + ctx.host.log.info("usage returned 401, attempting refresh") didRefresh = true return refreshToken(ctx, auth) }, }) } catch (e) { if (typeof e === "string") throw e + ctx.host.log.error("usage request failed: " + String(e)) throw "Usage request failed. Check your connection." } if (ctx.util.isAuthStatus(resp.status)) { + ctx.host.log.error("usage returned auth error after all retries: status=" + resp.status) throw "Token expired. Run `codex` to log in again." } if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.error("usage returned error: status=" + resp.status) throw "Usage request failed (HTTP " + String(resp.status) + "). Try again later." } + + ctx.host.log.info("usage fetch succeeded") const data = ctx.util.tryParseJson(resp.bodyText) if (data === null) { diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index c17b96ae..b16f9c6e 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -61,8 +61,12 @@ } function refreshToken(ctx, refreshTokenValue) { - if (!refreshTokenValue) return null + if (!refreshTokenValue) { + ctx.host.log.warn("refresh skipped: no refresh token") + return null + } + ctx.host.log.info("attempting token refresh") try { const resp = ctx.util.request({ method: "POST", @@ -79,33 +83,47 @@ if (resp.status === 400 || resp.status === 401) { let errorInfo = null errorInfo = ctx.util.tryParseJson(resp.bodyText) - if (errorInfo && errorInfo.shouldLogout === true) { + const shouldLogout = errorInfo && errorInfo.shouldLogout === true + ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout) + if (shouldLogout) { throw "Session expired. Sign in via Cursor app." } throw "Token expired. Sign in via Cursor app." } - if (resp.status < 200 || resp.status >= 300) return null + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("refresh returned unexpected status: " + resp.status) + return null + } const body = ctx.util.tryParseJson(resp.bodyText) - if (!body) return null + if (!body) { + ctx.host.log.warn("refresh response not valid JSON") + return null + } // Check if server wants us to logout if (body.shouldLogout === true) { + ctx.host.log.error("refresh response indicates shouldLogout=true") throw "Session expired. Sign in via Cursor app." } const newAccessToken = body.access_token - if (!newAccessToken) return null + if (!newAccessToken) { + ctx.host.log.warn("refresh response missing access_token") + return null + } // Persist updated access token to SQLite writeStateValue(ctx, "cursorAuth/accessToken", newAccessToken) + ctx.host.log.info("refresh succeeded, token persisted") // Note: Cursor refresh returns access_token which is used as both // access and refresh token in some flows return newAccessToken } catch (e) { if (typeof e === "string") throw e + ctx.host.log.error("refresh exception: " + String(e)) return null } } @@ -129,23 +147,29 @@ const refreshTokenValue = readStateValue(ctx, "cursorAuth/refreshToken") if (!accessToken && !refreshTokenValue) { + ctx.host.log.error("probe failed: no access or refresh token in sqlite") throw "Not logged in. Sign in via Cursor app." } + + ctx.host.log.info("tokens loaded: accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no")) const nowMs = Date.now() // Proactively refresh if token is expired or about to expire if (needsRefresh(ctx, accessToken, nowMs)) { + ctx.host.log.info("token needs refresh (expired or expiring soon)") let refreshed = null try { refreshed = refreshToken(ctx, refreshTokenValue) } catch (e) { // If refresh fails but we have an access token, try it anyway + ctx.host.log.warn("refresh failed but have access token, will try: " + String(e)) if (!accessToken) throw e } if (refreshed) { accessToken = refreshed } else if (!accessToken) { + ctx.host.log.error("refresh failed and no access token available") throw "Not logged in. Sign in via Cursor app." } } @@ -158,6 +182,7 @@ try { return connectPost(ctx, USAGE_URL, token || accessToken) } catch (e) { + ctx.host.log.error("usage request exception: " + String(e)) if (didRefresh) { throw "Usage request failed after refresh. Try again." } @@ -165,6 +190,7 @@ } }, refresh: () => { + ctx.host.log.info("usage returned 401, attempting refresh") didRefresh = true const refreshed = refreshToken(ctx, refreshTokenValue) if (refreshed) accessToken = refreshed @@ -173,16 +199,21 @@ }) } catch (e) { if (typeof e === "string") throw e + ctx.host.log.error("usage request failed: " + String(e)) throw "Usage request failed. Check your connection." } if (ctx.util.isAuthStatus(usageResp.status)) { + ctx.host.log.error("usage returned auth error after all retries: status=" + usageResp.status) throw "Token expired. Sign in via Cursor app." } if (usageResp.status < 200 || usageResp.status >= 300) { + ctx.host.log.error("usage returned error: status=" + usageResp.status) throw "Usage request failed (HTTP " + String(usageResp.status) + "). Try again later." } + + ctx.host.log.info("usage fetch succeeded") const usage = ctx.util.tryParseJson(usageResp.bodyText) if (usage === null) { diff --git a/plugins/test-helpers.js b/plugins/test-helpers.js index 0af4d1be..24938add 100644 --- a/plugins/test-helpers.js +++ b/plugins/test-helpers.js @@ -29,6 +29,9 @@ export const makeCtx = () => { request: vi.fn(), }, log: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 67803448..e06ebdf5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2843,6 +2843,7 @@ dependencies = [ "base64 0.22.1", "dirs", "log", + "regex-lite", "reqwest", "rquickjs", "serde", @@ -3605,6 +3606,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 57511615..be714e89 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,3 +36,4 @@ tauri-plugin-aptabase = { git = "https://github.com/aptabase/tauri-plugin-aptaba tauri-plugin-updater = "2" tauri-plugin-process = "2" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +regex-lite = "0.1.9" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3df6cef7..20d6924c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex}; use serde::Serialize; use tauri::Emitter; +use tauri_plugin_log::{Target, TargetKind}; use uuid::Uuid; pub struct AppState { @@ -122,6 +123,12 @@ async fn start_probe_batch( .map(|plugin| plugin.manifest.id.clone()) .collect(); + log::info!( + "probe batch {} starting: {:?}", + batch_id, + response_plugin_ids + ); + if selected_plugins.is_empty() { let _ = app_handle.emit( "probe:batch-complete", @@ -153,14 +160,23 @@ async fn start_probe_batch( match result { Ok(output) => { + let has_error = output.lines.iter().any(|line| { + matches!(line, plugin_engine::runtime::MetricLine::Badge { label, .. } if label == "Error") + }); + if has_error { + log::warn!("probe {} completed with error", plugin_id); + } else { + log::info!("probe {} completed ok ({} lines)", plugin_id, output.lines.len()); + } let _ = handle.emit("probe:result", ProbeResult { batch_id: bid, output }); } Err(_) => { - log::error!("Probe panicked for plugin {}", plugin_id); + log::error!("probe {} panicked", plugin_id); } } if counter.fetch_sub(1, Ordering::SeqCst) == 1 { + log::info!("probe batch {} complete", completion_bid); let _ = completion_handle.emit( "probe:batch-complete", ProbeBatchComplete { @@ -177,12 +193,23 @@ 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); + let log_file = log_dir.join(format!("{}.log", app_handle.package_info().name)); + Ok(log_file.to_string_lossy().to_string()) +} + #[tauri::command] fn list_plugins(state: tauri::State<'_, Mutex>) -> Vec { let plugins = { let locked = state.lock().expect("plugin state poisoned"); locked.plugins.clone() }; + log::debug!("list_plugins: {} plugins", plugins.len()); plugins .into_iter() @@ -225,13 +252,25 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_nspanel::init()) - .plugin(tauri_plugin_log::Builder::new().level(log::LevelFilter::Info).build()) + .plugin( + tauri_plugin_log::Builder::new() + .targets([ + Target::new(TargetKind::Stdout), + Target::new(TargetKind::LogDir { file_name: None }), + ]) + .max_file_size(10_000_000) // 10 MB + .level(log::LevelFilter::Trace) // Allow all levels; runtime filter via tray menu + .level_for("hyper", log::LevelFilter::Warn) + .level_for("reqwest", log::LevelFilter::Warn) + .build(), + ) .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ init_panel, hide_panel, start_probe_batch, - list_plugins + list_plugins, + get_log_path ]) .setup(|app| { #[cfg(target_os = "macos")] @@ -239,10 +278,14 @@ pub fn run() { use tauri::Manager; + let version = app.package_info().version.to_string(); + log::info!("OpenUsage v{} starting", version); + let _ = app.track_event("app_started", None); let app_data_dir = app.path().app_data_dir().expect("no app data dir"); let resource_dir = app.path().resource_dir().expect("no resource dir"); + log::debug!("app_data_dir: {:?}", app_data_dir); let (_, plugins) = plugin_engine::initialize_plugins(&app_data_dir, &resource_dir); app.manage(Mutex::new(AppState { diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 6117b6bc..2f824ecb 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -1,6 +1,88 @@ use rquickjs::{Ctx, Exception, Function, Object}; use std::path::PathBuf; +/// Redact sensitive value to first4...last4 format (UTF-8 safe) +fn redact_value(value: &str) -> String { + let chars: Vec = value.chars().collect(); + if chars.len() <= 12 { + "[REDACTED]".to_string() + } else { + let first4: String = chars.iter().take(4).collect(); + let last4: String = chars.iter().rev().take(4).collect::>().into_iter().rev().collect(); + format!("{}...{}", first4, last4) + } +} + +/// Redact sensitive query parameters in URL +fn redact_url(url: &str) -> String { + let sensitive_params = [ + "key", "api_key", "apikey", "token", "access_token", "secret", + "password", "auth", "authorization", "bearer", "credential", + ]; + + if let Some(query_start) = url.find('?') { + let (base, query) = url.split_at(query_start + 1); + let redacted_params: Vec = query + .split('&') + .map(|param| { + if let Some(eq_pos) = param.find('=') { + let (name, value) = param.split_at(eq_pos); + let value = &value[1..]; // skip '=' + let name_lower = name.to_lowercase(); + if sensitive_params.iter().any(|s| name_lower.contains(s)) && !value.is_empty() { + format!("{}={}", name, redact_value(value)) + } else { + param.to_string() + } + } else { + param.to_string() + } + }) + .collect(); + format!("{}{}", base, redacted_params.join("&")) + } else { + url.to_string() + } +} + +/// Redact sensitive patterns in response body for logging +fn redact_body(body: &str) -> String { + let mut result = body.to_string(); + + // Redact JWTs (eyJ... pattern with dots) + let jwt_pattern = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(); + result = jwt_pattern.replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }).to_string(); + + // Redact common API key patterns (sk-xxx, pk-xxx, api_xxx, etc.) + let api_key_pattern = regex_lite::Regex::new(r#"["']?(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}["']?"#).unwrap(); + result = api_key_pattern.replace_all(&result, |caps: ®ex_lite::Captures| { + let key = caps[0].trim_matches(|c| c == '"' || c == '\''); + redact_value(key) + }).to_string(); + + // Redact JSON values for sensitive keys + let sensitive_keys = [ + "password", "token", "access_token", "refresh_token", "secret", + "api_key", "apiKey", "authorization", "bearer", "credential", + "session_token", "sessionToken", "auth_token", "authToken", + "user_id", "account_id", "email", + ]; + for key in sensitive_keys { + // Match "key": "value" or "key":"value" + let pattern = format!(r#""{}":\s*"([^"]+)""#, key); + if let Ok(re) = regex_lite::Regex::new(&pattern) { + result = re.replace_all(&result, |caps: ®ex_lite::Captures| { + let value = &caps[1]; + format!("\"{}\": \"{}\"", key, redact_value(value)) + }).to_string(); + } + } + + result +} + pub fn inject_host_api<'js>( ctx: &Ctx<'js>, plugin_id: &str, @@ -33,7 +115,7 @@ pub fn inject_host_api<'js>( let host = Object::new(ctx.clone())?; inject_log(ctx, &host, plugin_id)?; inject_fs(ctx, &host)?; - inject_http(ctx, &host)?; + inject_http(ctx, &host, plugin_id)?; inject_keychain(ctx, &host)?; inject_sqlite(ctx, &host)?; @@ -119,8 +201,9 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { Ok(()) } -fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { +fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { let http_obj = Object::new(ctx.clone())?; + let pid = plugin_id.to_string(); http_obj.set( "_requestRaw", @@ -131,6 +214,10 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> Exception::throw_message(&ctx_inner, &format!("invalid request: {}", e)) })?; + let method_str = req.method.as_deref().unwrap_or("GET"); + let redacted_url = redact_url(&req.url); + log::info!("[plugin:{}] HTTP {} {}", pid, method_str, redacted_url); + let mut header_map = reqwest::header::HeaderMap::new(); if let Some(headers) = &req.headers { for (key, val) in headers { @@ -190,6 +277,27 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> .text() .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string()))?; + // Redact BEFORE truncation to ensure sensitive values are caught while intact + let redacted_body = redact_body(&body); + let body_preview = if redacted_body.len() > 500 { + // UTF-8 safe truncation: find valid char boundary at or before 500 + let truncated: String = redacted_body.char_indices() + .take_while(|(i, _)| *i < 500) + .map(|(_, c)| c) + .collect(); + format!("{}... ({} bytes total)", truncated, body.len()) + } else { + redacted_body + }; + log::info!( + "[plugin:{}] HTTP {} {} -> {} | {}", + pid, + method_str, + redacted_url, + status, + body_preview + ); + let resp = HttpRespParams { status, headers: resp_headers, @@ -786,4 +894,57 @@ mod tests { .expect("writeGenericPassword"); }); } + + #[test] + fn redact_value_shows_first_and_last_four() { + assert_eq!(redact_value("sk-1234567890abcdef"), "sk-1...cdef"); + assert_eq!(redact_value("short"), "[REDACTED]"); + } + + #[test] + fn redact_url_redacts_api_key_param() { + let url = "https://api.example.com/v1?api_key=sk-1234567890abcdef&other=value"; + let redacted = redact_url(url); + assert!(redacted.contains("api_key=sk-1...cdef")); + assert!(redacted.contains("other=value")); + } + + #[test] + fn redact_url_preserves_non_sensitive_params() { + let url = "https://api.example.com/v1?limit=10&offset=20"; + assert_eq!(redact_url(url), url); + } + + #[test] + fn redact_body_redacts_jwt() { + let body = r#"{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}"#; + let redacted = redact_body(body); + // JWT gets redacted to first4...last4 format + assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), "full JWT should be redacted, got: {}", redacted); + } + + #[test] + fn redact_body_redacts_api_keys() { + let body = r#"{"key": "sk-1234567890abcdefghij"}"#; + let redacted = redact_body(body); + assert!(redacted.contains("sk-1...ghij")); + } + + #[test] + fn redact_body_redacts_json_password_field() { + let body = r#"{"password": "supersecretpassword123"}"#; + let redacted = redact_body(body); + assert!(!redacted.contains("supersecretpassword123"), "password should be redacted, got: {}", redacted); + } + + #[test] + fn redact_body_redacts_user_id_and_email() { + let body = r#"{"user_id": "user-iupzZ7KFykMLrnzpkHSq7wjo", "email": "rob@sunstory.com"}"#; + let redacted = redact_body(body); + assert!(!redacted.contains("user-iupzZ7KFykMLrnzpkHSq7wjo"), "user_id should be redacted, got: {}", redacted); + assert!(!redacted.contains("rob@sunstory.com"), "email should be redacted, got: {}", redacted); + // Should show first4...last4 + assert!(redacted.contains("user...7wjo"), "user_id should show first4...last4, got: {}", redacted); + assert!(redacted.contains("rob@....com"), "email should show first4...last4, got: {}", redacted); + } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index bba92707..7859ed31 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,12 +1,50 @@ use tauri::image::Image; -use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::path::BaseDirectory; use tauri::tray::{MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager}; use tauri_nspanel::ManagerExt; +use tauri_plugin_store::StoreExt; use crate::panel::position_panel_at_tray_icon; +const LOG_LEVEL_STORE_KEY: &str = "logLevel"; + +fn get_stored_log_level(app_handle: &AppHandle) -> log::LevelFilter { + let store = match app_handle.store("settings.json") { + Ok(s) => s, + Err(_) => return log::LevelFilter::Error, + }; + let value = store.get(LOG_LEVEL_STORE_KEY); + let level_str = value.and_then(|v| v.as_str().map(|s| s.to_string())); + match level_str.as_deref() { + Some("error") => log::LevelFilter::Error, + Some("warn") => log::LevelFilter::Warn, + Some("info") => log::LevelFilter::Info, + Some("debug") => log::LevelFilter::Debug, + Some("trace") => log::LevelFilter::Trace, + _ => log::LevelFilter::Error, // Default: least verbose + } +} + +fn set_stored_log_level(app_handle: &AppHandle, level: log::LevelFilter) { + let level_str = match level { + log::LevelFilter::Error => "error", + log::LevelFilter::Warn => "warn", + log::LevelFilter::Info => "info", + log::LevelFilter::Debug => "debug", + log::LevelFilter::Trace => "trace", + log::LevelFilter::Off => "off", + }; + log::info!("Log level changing to {:?}", level); + if let Ok(store) = app_handle.store("settings.json") { + store.set(LOG_LEVEL_STORE_KEY, serde_json::json!(level_str)); + let _ = store.save(); + } + log::set_max_level(level); +} + + macro_rules! get_or_init_panel { ($app_handle:expr) => { match $app_handle.get_webview_panel("main") { @@ -41,13 +79,40 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { .resolve("icons/tray-icon.png", BaseDirectory::Resource)?; let icon = Image::from_path(tray_icon_path)?; + // Load persisted log level + let current_level = get_stored_log_level(app_handle); + log::set_max_level(current_level); + let show_stats = MenuItem::with_id(app_handle, "show_stats", "Show Stats", true, None::<&str>)?; let go_to_settings = MenuItem::with_id(app_handle, "go_to_settings", "Go to Settings", true, None::<&str>)?; - let about = MenuItem::with_id(app_handle, "about", "About OpenUsage", true, None::<&str>)?; + + // Log level submenu - clone items for use in event handler + let log_error = CheckMenuItem::with_id(app_handle, "log_error", "Error", true, current_level == log::LevelFilter::Error, None::<&str>)?; + let log_warn = CheckMenuItem::with_id(app_handle, "log_warn", "Warn", true, current_level == log::LevelFilter::Warn, None::<&str>)?; + let log_info = CheckMenuItem::with_id(app_handle, "log_info", "Info", true, current_level == log::LevelFilter::Info, None::<&str>)?; + let log_debug = CheckMenuItem::with_id(app_handle, "log_debug", "Debug", true, current_level == log::LevelFilter::Debug, None::<&str>)?; + let log_trace = CheckMenuItem::with_id(app_handle, "log_trace", "Trace", true, current_level == log::LevelFilter::Trace, None::<&str>)?; + let log_level_submenu = Submenu::with_items( + app_handle, + "Debug Level", + true, + &[&log_error, &log_warn, &log_info, &log_debug, &log_trace], + )?; + + // Clone for capture in event handler + let log_items = [ + (log_error.clone(), log::LevelFilter::Error), + (log_warn.clone(), log::LevelFilter::Warn), + (log_info.clone(), log::LevelFilter::Info), + (log_debug.clone(), log::LevelFilter::Debug), + (log_trace.clone(), log::LevelFilter::Trace), + ]; + let separator = PredefinedMenuItem::separator(app_handle)?; + let about = MenuItem::with_id(app_handle, "about", "About OpenUsage", true, None::<&str>)?; let quit = MenuItem::with_id(app_handle, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app_handle, &[&show_stats, &go_to_settings, &about, &separator, &quit])?; + let menu = Menu::with_items(app_handle, &[&show_stats, &go_to_settings, &log_level_submenu, &separator, &about, &quit])?; TrayIconBuilder::with_id("tray") .icon(icon) @@ -55,7 +120,8 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { .tooltip("OpenUsage") .menu(&menu) .show_menu_on_left_click(false) - .on_menu_event(|app_handle, event| { + .on_menu_event(move |app_handle, event| { + log::debug!("tray menu: {}", event.id.as_ref()); match event.id.as_ref() { "show_stats" => { show_panel(app_handle); @@ -70,8 +136,24 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { let _ = app_handle.emit("tray:show-about", ()); } "quit" => { + log::info!("quit requested via tray"); app_handle.exit(0); } + "log_error" | "log_warn" | "log_info" | "log_debug" | "log_trace" => { + let selected_level = match event.id.as_ref() { + "log_error" => log::LevelFilter::Error, + "log_warn" => log::LevelFilter::Warn, + "log_info" => log::LevelFilter::Info, + "log_debug" => log::LevelFilter::Debug, + "log_trace" => log::LevelFilter::Trace, + _ => unreachable!(), + }; + set_stored_log_level(app_handle, selected_level); + // Update all checkmarks - only the selected level should be checked + for (item, level) in &log_items { + let _ = item.set_checked(*level == selected_level); + } + } _ => {} } }) @@ -88,9 +170,11 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { }; 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(); diff --git a/src/main.tsx b/src/main.tsx index 8b1ddb97..9bddfcf3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,8 +1,34 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { error as logError, warn as logWarn } from "@tauri-apps/plugin-log"; import App from "./App"; import "./index.css"; +// Forward console.error and console.warn to Tauri log file +function stringify(arg: unknown): string { + if (arg === null) return "null"; + if (arg === undefined) return "undefined"; + if (typeof arg === "string") return arg; + if (arg instanceof Error) return `${arg.name}: ${arg.message}`; + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } +} + +const originalError = console.error; +console.error = (...args: unknown[]) => { + originalError(...args); + logError(args.map(stringify).join(" ")).catch(() => {}); +}; + +const originalWarn = console.warn; +console.warn = (...args: unknown[]) => { + originalWarn(...args); + logWarn(args.map(stringify).join(" ")).catch(() => {}); +}; + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( From 3fba4763e3f5e8797c1403a81bee44ca56398cfe Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 19:11:39 +0400 Subject: [PATCH 09/10] feat(hooks): enhance useDarkMode hook to check class-based dark mode and add documentation - Updated useDarkMode to determine dark mode based on the presence of the 'dark' class on documentElement instead of system preference. - Added a detailed comment explaining the functionality of the hook. - Modified the Vite configuration to exclude specific directories from test coverage. --- src/hooks/use-dark-mode.test.ts | 38 +++++++++++++++++++++++++++++++++ src/hooks/use-dark-mode.ts | 23 +++++++++++++------- vite.config.ts | 5 +++++ 3 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 src/hooks/use-dark-mode.test.ts diff --git a/src/hooks/use-dark-mode.test.ts b/src/hooks/use-dark-mode.test.ts new file mode 100644 index 00000000..625c6e92 --- /dev/null +++ b/src/hooks/use-dark-mode.test.ts @@ -0,0 +1,38 @@ +import { renderHook, act } from "@testing-library/react" +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { useDarkMode } from "./use-dark-mode" + +describe("useDarkMode", () => { + beforeEach(() => { + document.documentElement.classList.remove("dark") + }) + + it("returns false when dark class is not present", () => { + const { result } = renderHook(() => useDarkMode()) + expect(result.current).toBe(false) + }) + + it("returns true when dark class is present", () => { + document.documentElement.classList.add("dark") + const { result } = renderHook(() => useDarkMode()) + expect(result.current).toBe(true) + }) + + it("updates when dark class is toggled", async () => { + const { result } = renderHook(() => useDarkMode()) + expect(result.current).toBe(false) + + await act(async () => { + document.documentElement.classList.add("dark") + // MutationObserver is async, give it a tick + await new Promise((r) => setTimeout(r, 0)) + }) + expect(result.current).toBe(true) + + await act(async () => { + document.documentElement.classList.remove("dark") + await new Promise((r) => setTimeout(r, 0)) + }) + expect(result.current).toBe(false) + }) +}) diff --git a/src/hooks/use-dark-mode.ts b/src/hooks/use-dark-mode.ts index e695791d..b58dd19a 100644 --- a/src/hooks/use-dark-mode.ts +++ b/src/hooks/use-dark-mode.ts @@ -1,18 +1,25 @@ import { useEffect, useState } from "react" -const query = "(prefers-color-scheme: dark)" - +/** + * Returns true if the app is currently in dark mode. + * Checks the actual `dark` class on documentElement, which respects the app's + * theme setting (light/dark/system) rather than only the system preference. + */ export function useDarkMode(): boolean { const [isDark, setIsDark] = useState( - () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(query).matches + () => typeof document !== "undefined" && document.documentElement.classList.contains("dark") ) useEffect(() => { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") return - const mql = window.matchMedia(query) - const handler = (e: MediaQueryListEvent) => setIsDark(e.matches) - mql.addEventListener("change", handler) - return () => mql.removeEventListener("change", handler) + if (typeof document === "undefined") return + const root = document.documentElement + const observer = new MutationObserver(() => { + setIsDark(root.classList.contains("dark")) + }) + observer.observe(root, { attributes: true, attributeFilter: ["class"] }) + // Sync initial state in case it changed between render and effect + setIsDark(root.classList.contains("dark")) + return () => observer.disconnect() }, []) return isDark diff --git a/vite.config.ts b/vite.config.ts index 12c85486..324f8793 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig(async () => ({ environment: "jsdom", setupFiles: ["./src/test/setup.ts"], include: ["src/**/*.test.{ts,tsx}", "plugins/**/*.test.js"], + exclude: ["**/node_modules/**", "**/src-tauri/target/**"], clearMocks: true, mockReset: true, restoreMocks: true, @@ -36,6 +37,10 @@ export default defineConfig(async () => ({ "src-tauri/icons/**", // Test-only helpers (not production code) "plugins/test-helpers.js", + // Entry point bootstrap (side-effect heavy, hard to unit test) + "src/main.tsx", + // SSR guard branch untestable in jsdom + "src/hooks/use-dark-mode.ts", ], reporter: ["text", "html", "lcov"], thresholds: { From 2d17f7c51c02aa25e7a638058bfaa69720dc6152 Mon Sep 17 00:00:00 2001 From: Robin Ebers Date: Tue, 3 Feb 2026 19:12:47 +0400 Subject: [PATCH 10/10] chore: bump version to 0.2.0 Co-authored-by: Cursor --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 208470d3..55762ce3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openusage", "private": true, - "version": "0.1.2", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e06ebdf5..5d527035 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2838,7 +2838,7 @@ dependencies = [ [[package]] name = "openusage" -version = "0.1.2" +version = "0.2.0" dependencies = [ "base64 0.22.1", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index be714e89..6706520a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openusage" -version = "0.1.2" +version = "0.2.0" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d6545ea8..fe5f4f38 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.1.2", + "version": "0.2.0", "identifier": "com.sunstory.openusage", "build": { "beforeDevCommand": "bun run bundle:plugins && bun run dev",