From 0c97ad656011d041c5198d97c45d22c9ed75842e Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 17:11:19 +1000 Subject: [PATCH 1/6] feat: prune history events older than 90 days on write --- crates/openwith-core/src/history.rs | 57 +++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/crates/openwith-core/src/history.rs b/crates/openwith-core/src/history.rs index 5d6f2b7..03ed5c3 100644 --- a/crates/openwith-core/src/history.rs +++ b/crates/openwith-core/src/history.rs @@ -13,6 +13,9 @@ use serde::{Deserialize, Serialize}; /// Keep the log bounded; older events fall off the front. const MAX_EVENTS: usize = 500; +/// Events older than this are pruned on every write. +const MAX_AGE_SECS: u64 = 90 * 24 * 60 * 60; + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] pub struct HistoryEvent { /// "set" | "set_scheme" | "export" | "import" @@ -87,6 +90,8 @@ fn load(path: &Path) -> Vec { pub fn record_at(path: &Path, event: HistoryEvent) -> Result<()> { let mut events = load(path); events.push(event); + let cutoff = now_secs().saturating_sub(MAX_AGE_SECS); + events.retain(|e| e.timestamp >= cutoff); if events.len() > MAX_EVENTS { events.drain(0..events.len() - MAX_EVENTS); } @@ -147,6 +152,12 @@ mod tests { )) } + /// Test timestamps are offsets from now: `record_at` prunes anything older + /// than MAX_AGE_SECS, so absolute small values would vanish on write. + fn ts(offset: u64) -> u64 { + now_secs() - 10_000 + offset + } + fn event(kind: &str, key: &str, ts: u64) -> HistoryEvent { HistoryEvent { kind: kind.into(), @@ -162,8 +173,8 @@ mod tests { let path = temp_log("roundtrip"); let _ = std::fs::remove_file(&path); - record_at(&path, event("set", ".md", 1)).unwrap(); - record_at(&path, event("export", "openwith.toml", 2)).unwrap(); + record_at(&path, event("set", ".md", ts(1))).unwrap(); + record_at(&path, event("export", "openwith.toml", ts(2))).unwrap(); let events = recent_at(&path, 10).unwrap(); assert_eq!(events.len(), 2); @@ -186,7 +197,7 @@ mod tests { std::fs::write(&path, "not json").unwrap(); assert!(recent_at(&path, 5).unwrap().is_empty()); - record_at(&path, event("import", "a.toml", 3)).unwrap(); + record_at(&path, event("import", "a.toml", ts(3))).unwrap(); assert_eq!(recent_at(&path, 5).unwrap().len(), 1); std::fs::remove_file(&path).unwrap(); @@ -197,20 +208,20 @@ mod tests { let path = temp_log("undone"); let _ = std::fs::remove_file(&path); - let mut set = event("set", ".md", 10); + let mut set = event("set", ".md", ts(10)); set.old = Some("a".into()); set.new = Some("b".into()); record_at(&path, set).unwrap(); - record_at(&path, event("export", "x.toml", 11)).unwrap(); + record_at(&path, event("export", "x.toml", ts(11))).unwrap(); assert!(recent_at(&path, 5).unwrap()[1].undoable()); - mark_undone_at(&path, "set", ".md", 10, Some("b")).unwrap(); + mark_undone_at(&path, "set", ".md", ts(10), Some("b")).unwrap(); let events = recent_at(&path, 5).unwrap(); assert!(events[1].undone); assert!(!events[1].undoable()); // unknown event → silent no-op - mark_undone_at(&path, "set", ".zzz", 99, None).unwrap(); + mark_undone_at(&path, "set", ".zzz", ts(99), None).unwrap(); std::fs::remove_file(&path).unwrap(); } @@ -222,17 +233,17 @@ mod tests { // A set and another event in the same second, the newer one already // undone — marking must flag the still-active older twin. - let mut a = event("set", ".md", 10); + let mut a = event("set", ".md", ts(10)); a.old = Some("typora".into()); a.new = Some("textedit".into()); - let mut b = event("set", ".md", 10); + let mut b = event("set", ".md", ts(10)); b.old = Some("textedit".into()); b.new = Some("typora".into()); b.undone = true; record_at(&path, a).unwrap(); record_at(&path, b).unwrap(); - mark_undone_at(&path, "set", ".md", 10, Some("textedit")).unwrap(); + mark_undone_at(&path, "set", ".md", ts(10), Some("textedit")).unwrap(); let events = recent_at(&path, 5).unwrap(); assert!(events.iter().all(|e| e.undone)); @@ -245,12 +256,34 @@ mod tests { fn log_is_capped() { let path = temp_log("cap"); let _ = std::fs::remove_file(&path); + // Pin the base once: the write loop takes real time, and a moving + // now_secs() would shift ts() between the loop and the assertion. + let base = ts(0); for i in 0..(MAX_EVENTS as u64 + 20) { - record_at(&path, event("set", ".md", i)).unwrap(); + record_at(&path, event("set", ".md", base + i)).unwrap(); } let events = recent_at(&path, MAX_EVENTS + 50).unwrap(); assert_eq!(events.len(), MAX_EVENTS); - assert_eq!(events[0].timestamp, MAX_EVENTS as u64 + 19); + assert_eq!(events[0].timestamp, base + MAX_EVENTS as u64 + 19); + + std::fs::remove_file(&path).unwrap(); + } + + #[test] + fn old_events_are_pruned_on_write() { + let path = temp_log("prune"); + let _ = std::fs::remove_file(&path); + + // Seed the file directly: record_at would refuse to keep stale events. + let stale = event("set", ".old", now_secs() - MAX_AGE_SECS - 60); + let fresh = event("set", ".fresh", ts(1)); + std::fs::write(&path, serde_json::to_string(&vec![stale, fresh]).unwrap()).unwrap(); + + record_at(&path, event("set", ".new", ts(2))).unwrap(); + + let events = recent_at(&path, 10).unwrap(); + assert_eq!(events.len(), 2); + assert!(events.iter().all(|e| e.key != ".old")); std::fs::remove_file(&path).unwrap(); } From 9eb4a5458c14d7c903120d0644260e08c5161a36 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 17:11:19 +1000 Subject: [PATCH 2/6] fix: main window reopens after close; tray toggle no longer duplicates icons Closing the main window now hides it (standard macOS behavior) and a RunEvent::Reopen handler restores it from the Dock. Tray removal goes through the app's tray registry (remove_tray_by_id) instead of dropping a handle, which leaked one icon per off/on cycle. Adds set_dock_visible for the new hide-Dock-icon setting. --- crates/openwith-gui/src-tauri/src/commands.rs | 22 ++++++++++++-- crates/openwith-gui/src-tauri/src/lib.rs | 24 +++++++++++---- crates/openwith-gui/src-tauri/src/tray.rs | 29 +++++++++---------- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/crates/openwith-gui/src-tauri/src/commands.rs b/crates/openwith-gui/src-tauri/src/commands.rs index a1cdc96..9cf4409 100644 --- a/crates/openwith-gui/src-tauri/src/commands.rs +++ b/crates/openwith-gui/src-tauri/src/commands.rs @@ -275,8 +275,9 @@ pub fn undo_change( }) } -#[tauri::command] -pub fn show_main_window(app: AppHandle) { +/// Bring the main window forward; shared by the popover's "Open main window" +/// command and the dock-icon Reopen event in lib.rs. +pub fn show_main(app: &AppHandle) { if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); @@ -287,6 +288,11 @@ pub fn show_main_window(app: AppHandle) { } } +#[tauri::command] +pub fn show_main_window(app: AppHandle) { + show_main(&app); +} + #[tauri::command] pub fn quit_app(app: AppHandle) { app.exit(0); @@ -297,6 +303,18 @@ pub fn set_tray_enabled(app: AppHandle, enabled: bool) -> Result<(), String> { tray::set_enabled(&app, enabled).map_err(|e| e.to_string()) } +/// Show or hide the Dock icon by switching the activation policy. Hiding the +/// dock while keeping the tray gives a menu-bar-only app (Accessory mode). +#[tauri::command] +pub fn set_dock_visible(app: AppHandle, visible: bool) -> Result<(), String> { + let policy = if visible { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }; + app.set_activation_policy(policy).map_err(|e| e.to_string()) +} + #[derive(Serialize)] pub struct HistoryEventDto { pub kind: String, diff --git a/crates/openwith-gui/src-tauri/src/lib.rs b/crates/openwith-gui/src-tauri/src/lib.rs index 190c7d4..a7c550e 100644 --- a/crates/openwith-gui/src-tauri/src/lib.rs +++ b/crates/openwith-gui/src-tauri/src/lib.rs @@ -28,12 +28,19 @@ pub fn run() { .build(), ) .manage(commands::AppsCache::default()) - .manage(tray::TrayState::default()) - .on_window_event(|window, event| { + .on_window_event(|window, event| match (window.label(), event) { // The popover behaves like a menu: clicking anywhere else closes it. - if window.label() == "menubar" && matches!(event, tauri::WindowEvent::Focused(false)) { + ("menubar", tauri::WindowEvent::Focused(false)) => { let _ = window.hide(); } + // Standard macOS behavior: the close button hides the window, the + // app keeps running (⌘Q quits). Destroying it would make the app + // unreopenable — the hidden popover window keeps it alive. + ("main", tauri::WindowEvent::CloseRequested { api, .. }) => { + api.prevent_close(); + let _ = window.hide(); + } + _ => {} }) .invoke_handler(tauri::generate_handler![ commands::detect_cli, @@ -51,7 +58,14 @@ pub fn run() { commands::show_main_window, commands::quit_app, commands::set_tray_enabled, + commands::set_dock_visible, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app, event| { + // Dock icon clicked with no visible window: reopen main. + if let tauri::RunEvent::Reopen { .. } = event { + commands::show_main(app); + } + }); } diff --git a/crates/openwith-gui/src-tauri/src/tray.rs b/crates/openwith-gui/src-tauri/src/tray.rs index 80327b7..13de7c6 100644 --- a/crates/openwith-gui/src-tauri/src/tray.rs +++ b/crates/openwith-gui/src-tauri/src/tray.rs @@ -1,32 +1,28 @@ -use std::sync::Mutex; - -use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Manager}; use tauri_plugin_positioner::{Position, WindowExt}; -/// The live tray icon, if the "Show in menu bar" setting is on. -#[derive(Default)] -pub struct TrayState(pub Mutex>); +const TRAY_ID: &str = "openwith-tray"; +/// Add or remove the menu-bar icon. The app's tray registry is the source of +/// truth: `TrayIconBuilder::build` registers the icon there, so removal must +/// go through `remove_tray_by_id` — dropping a handle alone leaks the icon. pub fn set_enabled(app: &AppHandle, enabled: bool) -> tauri::Result<()> { - let state = app.state::(); - let mut slot = state.0.lock().expect("tray state poisoned"); if enabled { - if slot.is_none() { - *slot = Some(build(app)?); + if app.tray_by_id(TRAY_ID).is_none() { + build(app)?; } - } else if let Some(tray) = slot.take() { - // Dropping the handle removes the icon from the menu bar. - drop(tray); + } else { + app.remove_tray_by_id(TRAY_ID); } Ok(()) } -fn build(app: &AppHandle) -> tauri::Result { +fn build(app: &AppHandle) -> tauri::Result<()> { // Monochrome template image: macOS recolors it for light/dark menu bars // and the pressed state, like native status items. let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray-template.png"))?; - TrayIconBuilder::with_id("openwith-tray") + TrayIconBuilder::with_id(TRAY_ID) .tooltip("OpenWith") .icon(icon) .icon_as_template(true) @@ -41,7 +37,8 @@ fn build(app: &AppHandle) -> tauri::Result { toggle_popover(tray.app_handle()); } }) - .build(app) + .build(app)?; + Ok(()) } pub fn toggle_popover(app: &AppHandle) { From 142e608f8c3fd4a70454001cffbb7dd2c8afa7d1 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 17:11:33 +1000 Subject: [PATCH 3/6] feat: appearance setting, hide-Dock toggle, persistent dry-run panel, scrollable history - Appearance (System/Light/Dark): resolved theme stamped as data-theme on ; dark palette re-keyed off it. Both windows restyle live (matchMedia for System, storage event for the popover). - Hide Dock icon toggle, gated on the menu bar icon being on; turning the tray off forces the Dock icon back. - Profiles: DRY-RUN PREVIEW panel is now a persistent fixture with a placeholder; history panel capped at 260px and scrolls. - Beta/Stable channel switch re-runs the update check immediately; update hint uses the renamed openwith-gui cask. --- crates/openwith-gui/src/api.ts | 2 + crates/openwith-gui/src/app.ts | 44 +++++++++- crates/openwith-gui/src/main.ts | 3 + crates/openwith-gui/src/state.ts | 6 ++ crates/openwith-gui/src/styles.css | 133 ++++++++++++++--------------- crates/openwith-gui/src/theme.ts | 38 +++++++++ 6 files changed, 156 insertions(+), 70 deletions(-) create mode 100644 crates/openwith-gui/src/theme.ts diff --git a/crates/openwith-gui/src/api.ts b/crates/openwith-gui/src/api.ts index 73369f5..16817ad 100644 --- a/crates/openwith-gui/src/api.ts +++ b/crates/openwith-gui/src/api.ts @@ -120,4 +120,6 @@ export const api = { quitApp: () => invoke("quit_app"), setTrayEnabled: (enabled: boolean) => invoke("set_tray_enabled", { enabled }), + setDockVisible: (visible: boolean) => + invoke("set_dock_visible", { visible }), }; diff --git a/crates/openwith-gui/src/app.ts b/crates/openwith-gui/src/app.ts index 3f77892..8152a1c 100644 --- a/crates/openwith-gui/src/app.ts +++ b/crates/openwith-gui/src/app.ts @@ -25,6 +25,7 @@ import { state, type Tab, } from "./state"; +import { applyTheme, type Appearance } from "./theme"; const root = document.getElementById("app")!; @@ -286,13 +287,22 @@ function renderProfiles(): string { - ${state.importPending ? renderImportPreview() : ""} + ${renderImportPreview()} ${renderHistory()} `; } function renderImportPreview(): string { - const pending = state.importPending!; + const pending = state.importPending; + // The panel is a fixture of the view (like the prototype): it explains the + // import flow even when no file is staged. + if (!pending) { + return ` +
+
DRY-RUN PREVIEW
+
Drop or choose a .toml to preview changes before applying.
+
`; + } const preview = pending.preview; const lines: string[] = []; for (const a of preview.applied) { @@ -362,7 +372,7 @@ function updateStatusLine(): string { const v = state.appVersion; if (u.error) return `Check failed — ${escapeHtml(u.error)}`; if (u.latest && v && u.latest !== v) - return `Update ${escapeHtml(u.latest)} available — brew upgrade --cask openwith`; + return `Update ${escapeHtml(u.latest)} available — brew upgrade --cask openwith-gui`; if (u.latest && u.checkedAt) return `✓ Up to date · last checked ${escapeHtml(u.checkedAt)}`; return `Updates ship via Homebrew`; @@ -383,6 +393,11 @@ function renderSettings(): string {
GENERAL
${toggleRow("launchAtLogin", s.launchAtLogin, "Launch at login", "Start OpenWith when you log in")} ${toggleRow("showMenuBar", s.showMenuBar, "Show in menu bar", "Quick-access panel with ⌥⌘O")} + ${toggleRow("hideDockIcon", s.hideDockIcon, "Hide Dock icon", "Menu-bar-only mode — needs the menu bar icon on", !s.showMenuBar)} +
+ AppearanceSystem follows your macOS setting + ${segmented("set-appearance", "appearance", [{ key: "system", label: "System" }, { key: "light", label: "Light" }, { key: "dark", label: "Dark" }], s.appearance)} +
Open on tabWhich view the main window starts on ${segmented("set-open-tab", "tab", [{ key: "extensions", label: "Extensions" }, { key: "apps", label: "Apps" }], s.openOnTab)} @@ -947,12 +962,18 @@ root.addEventListener("click", (e) => { const key = target.dataset.toggle as | "launchAtLogin" | "showMenuBar" + | "hideDockIcon" | "confirmBeforeApplying" | "warnUtiConflicts" | "showBundleIds" | "relaunchFinder" | "autoUpdateCheck"; state.settings[key] = !state.settings[key]; + if (key === "showMenuBar" && !state.settings.showMenuBar) { + // Never both hidden: losing the tray forces the Dock icon back. + state.settings.hideDockIcon = false; + api.setDockVisible(true).catch(() => {}); + } saveSettings(); if (key === "launchAtLogin") { void applyLaunchAtLogin(state.settings.launchAtLogin); @@ -961,6 +982,11 @@ root.addEventListener("click", (e) => { state.toast = { text: "Couldn't update the menu bar icon" }; render(); }); + } else if (key === "hideDockIcon") { + api.setDockVisible(!state.settings.hideDockIcon).catch(() => { + state.toast = { text: "Couldn't change the Dock icon" }; + render(); + }); } render(); break; @@ -970,9 +996,18 @@ root.addEventListener("click", (e) => { saveSettings(); render(); break; + case "set-appearance": + state.settings.appearance = target.dataset.appearance as Appearance; + saveSettings(); + // Restyles this window now; the popover follows via its storage event. + applyTheme(); + render(); + break; case "set-channel": state.settings.updateChannel = target.dataset.channel as "stable" | "beta"; saveSettings(); + // Re-check immediately so the status line reflects the new channel. + void checkForUpdates(); render(); break; } @@ -1058,6 +1093,9 @@ async function bootstrap() { // Apply persisted preferences that live outside the webview. api.setTrayEnabled(state.settings.showMenuBar).catch(() => {}); + if (state.settings.hideDockIcon && state.settings.showMenuBar) { + api.setDockVisible(false).catch(() => {}); + } autostartEnabled() .then((actual) => { if (actual !== state.settings.launchAtLogin) { diff --git a/crates/openwith-gui/src/main.ts b/crates/openwith-gui/src/main.ts index 37a28df..7732d08 100644 --- a/crates/openwith-gui/src/main.ts +++ b/crates/openwith-gui/src/main.ts @@ -1,5 +1,8 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +// Stamps data-theme on before either UI renders. +import "./theme"; + // One Vite bundle serves both windows; the label picks the UI. if (getCurrentWebviewWindow().label === "menubar") { document.documentElement.classList.add("popover-window"); diff --git a/crates/openwith-gui/src/state.ts b/crates/openwith-gui/src/state.ts index 4bdc9c5..cf70bd2 100644 --- a/crates/openwith-gui/src/state.ts +++ b/crates/openwith-gui/src/state.ts @@ -43,6 +43,10 @@ export interface UpdateStatus { export interface SettingsState { launchAtLogin: boolean; showMenuBar: boolean; + /** Menu-bar-only mode. Only honored while showMenuBar is on — the app must + * stay reachable somewhere. */ + hideDockIcon: boolean; + appearance: import("./theme").Appearance; confirmBeforeApplying: boolean; warnUtiConflicts: boolean; showBundleIds: boolean; @@ -57,6 +61,8 @@ const SETTINGS_KEY = "openwith.settings"; const DEFAULT_SETTINGS: SettingsState = { launchAtLogin: false, showMenuBar: true, + hideDockIcon: false, + appearance: "system", confirmBeforeApplying: false, warnUtiConflicts: true, showBundleIds: true, diff --git a/crates/openwith-gui/src/styles.css b/crates/openwith-gui/src/styles.css index b3ffcf0..0c2f44b 100644 --- a/crates/openwith-gui/src/styles.css +++ b/crates/openwith-gui/src/styles.css @@ -59,59 +59,60 @@ color-scheme: light; } -@media (prefers-color-scheme: dark) { - :root { - --bg: #201d1b; - --panel: #292521; - --panel-border: #3a352f; - --header-border: #332f2a; - --row-border: #2f2b26; - --field-bg: #332e29; - --text: #ece7df; - --text-muted: #a89f92; - --text-faint: #7a7268; - --text-faintest: #7a7268; - - --accent: oklch(0.72 0.13 45); - --accent-strong: oklch(0.78 0.11 45); - --accent-hover: oklch(0.78 0.11 45); - --accent-soft: oklch(0.32 0.06 45); - --accent-soft-hover: oklch(0.37 0.07 45); - --accent-border: oklch(0.5 0.09 45); - - --tab-inactive: #8f887d; - - --warn-text: #d9b45c; - --warn-bg: #3a3222; - --warn-border: #55482a; - --warn-bg-soft: #3a3222; - --warn-border-soft: #55482a; - - --ok-text: #7fce9e; - --ok-bg: #24382c; - - --claim-border: #4a443c; - - --row-selected: #332e29; - - --toast-bg: #ece7df; - --toast-text: #2b2926; - --toast-accent: oklch(0.52 0.14 45); - - --toggle-on: oklch(0.66 0.13 45); - --toggle-track-off: #4a443c; - --pill-bg: #332e29; - --pill-hover: #3f3933; - --seg-active: #3f3933; - - --panel-head-bg: #242019; - - --sheet-overlay: rgba(0, 0, 0, 0.45); - --sheet-bg: #2a2724; - --sheet-current-bg: oklch(0.3 0.05 45); - - color-scheme: dark; - } +/* Dark theme is applied by stamping data-theme="dark" on (main.ts): + the Appearance setting resolves System via matchMedia, so CSS never needs + the media query directly. */ +:root[data-theme="dark"] { + --bg: #201d1b; + --panel: #292521; + --panel-border: #3a352f; + --header-border: #332f2a; + --row-border: #2f2b26; + --field-bg: #332e29; + --text: #ece7df; + --text-muted: #a89f92; + --text-faint: #7a7268; + --text-faintest: #7a7268; + + --accent: oklch(0.72 0.13 45); + --accent-strong: oklch(0.78 0.11 45); + --accent-hover: oklch(0.78 0.11 45); + --accent-soft: oklch(0.32 0.06 45); + --accent-soft-hover: oklch(0.37 0.07 45); + --accent-border: oklch(0.5 0.09 45); + + --tab-inactive: #8f887d; + + --warn-text: #d9b45c; + --warn-bg: #3a3222; + --warn-border: #55482a; + --warn-bg-soft: #3a3222; + --warn-border-soft: #55482a; + + --ok-text: #7fce9e; + --ok-bg: #24382c; + + --claim-border: #4a443c; + + --row-selected: #332e29; + + --toast-bg: #ece7df; + --toast-text: #2b2926; + --toast-accent: oklch(0.52 0.14 45); + + --toggle-on: oklch(0.66 0.13 45); + --toggle-track-off: #4a443c; + --pill-bg: #332e29; + --pill-hover: #3f3933; + --seg-active: #3f3933; + + --panel-head-bg: #242019; + + --sheet-overlay: rgba(0, 0, 0, 0.45); + --sheet-bg: #2a2724; + --sheet-current-bg: oklch(0.3 0.05 45); + + color-scheme: dark; } * { @@ -685,11 +686,9 @@ body { font-family: inherit; } -@media (prefers-color-scheme: dark) { - .btn-primary { - color: var(--bg); - background: var(--text); - } +[data-theme="dark"] .btn-primary { + color: var(--bg); + background: var(--text); } .dropzone { @@ -785,6 +784,8 @@ body { /* history panel (profiles) */ .history-body { padding: 4px 6px; + max-height: 260px; + overflow-y: auto; } .history-row { @@ -1173,15 +1174,13 @@ body { --pop-picker-bg: #fff; } -@media (prefers-color-scheme: dark) { - :root { - --pop-bg: rgba(32, 29, 27, 0.94); - --pop-drop-bg: #292521; - --pop-drop-text: #a89f92; - --pop-footer-text: #a89f92; - --pop-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); - --pop-picker-bg: var(--sheet-bg); - } +:root[data-theme="dark"] { + --pop-bg: rgba(32, 29, 27, 0.94); + --pop-drop-bg: #292521; + --pop-drop-text: #a89f92; + --pop-footer-text: #a89f92; + --pop-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); + --pop-picker-bg: var(--sheet-bg); } html.popover-window, diff --git a/crates/openwith-gui/src/theme.ts b/crates/openwith-gui/src/theme.ts new file mode 100644 index 0000000..d1bf598 --- /dev/null +++ b/crates/openwith-gui/src/theme.ts @@ -0,0 +1,38 @@ +/** Theme resolution shared by both windows. The Appearance setting + * (system / light / dark) lives in localStorage with the rest of the + * settings; the resolved theme is stamped as data-theme on , which + * styles.css keys its dark palette off. */ + +const SETTINGS_KEY = "openwith.settings"; + +export type Appearance = "system" | "light" | "dark"; + +function storedAppearance(): Appearance { + try { + const raw = localStorage.getItem(SETTINGS_KEY); + const value = raw ? (JSON.parse(raw) as { appearance?: unknown }).appearance : undefined; + return value === "light" || value === "dark" ? value : "system"; + } catch { + return "system"; + } +} + +const systemDark = window.matchMedia("(prefers-color-scheme: dark)"); + +/** Stamp the resolved theme on . Call whenever the setting changes. */ +export function applyTheme(): void { + const appearance = storedAppearance(); + const dark = + appearance === "system" ? systemDark.matches : appearance === "dark"; + document.documentElement.dataset.theme = dark ? "dark" : "light"; +} + +// Follow the OS live while the setting is "system". +systemDark.addEventListener("change", applyTheme); + +// `storage` fires only in *other* windows of the same origin — exactly the +// cross-window path: changing the setting in the main window restyles the +// open menu-bar popover (and vice versa). +window.addEventListener("storage", applyTheme); + +applyTheme(); From fbd7acf81e5fd415dce9091956af28d41a6a4c92 Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 17:11:33 +1000 Subject: [PATCH 4/6] docs: two-track install (formula vs openwith-gui cask), GUI smoke-test checklist --- CLAUDE.md | 36 +++++++++++++++++++++++++++--------- README.md | 19 ++++++++++++------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 42e492d..a5bbeb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,7 @@ When releasing, update `version` in the root `Cargo.toml` (`workspace.package`) cargo test (cd crates/openwith-gui && npm run build) ``` + If the release touches the GUI, also run the **GUI smoke-test checklist** below against a real `npm run tauri build` bundle — mandatory before tagging. 4. Create a git tag: `git tag vX.Y.Z` 5. Push the tag: `git push origin vX.Y.Z` 6. Create GitHub release with `gh release create` using the appropriate template below. @@ -150,7 +151,22 @@ When releasing, update `version` in the root `Cargo.toml` (`workspace.package`) ``` The app is unsigned (no Apple Developer ID yet) — first launch needs `xattr -dr com.apple.quarantine /Applications/OpenWith.app` or right-click → Open. 8. Bump the Homebrew formula in `ColeMei/homebrew-openwith` (url + sha256 of the new tag tarball). Since the workspace conversion, the formula's `install` block must use `system "cargo", "install", *std_cargo_args, "--path", "crates/openwith-cli"` (the repo root is now a virtual workspace with no installable package at `.`). -9. Update the `openwith` cask in `ColeMei/homebrew-openwith` (url + sha256 of the .dmg release asset) with the quarantine caveat, so `brew install --cask` works for the GUI. +9. Update the `openwith-gui` cask in `ColeMei/homebrew-openwith` (`Casks/openwith-gui.rb`, url + sha256 of the .dmg release asset) with the quarantine caveat, so `brew install --cask ColeMei/openwith/openwith-gui` works for the GUI. (The cask was named `openwith` before v0.5.2.) + +### GUI smoke-test checklist + +Run against the built .app (not just `tauri dev`) before tagging any release with GUI changes. Naive "it compiles + the window opens" testing has shipped real bugs; every control must be exercised for a *real observable effect* (confirm sets/undos with `openwith current `). + +- [ ] Close the main window, reopen via Dock click AND via popover "Open main window" — repeat ×3 +- [ ] Toggle "Show in menu bar" off/on ×3 — exactly one tray icon at every step +- [ ] Hide Dock icon on/off; then turn the tray off while the Dock is hidden — Dock icon must come back +- [ ] Appearance: flip System/Light/Dark with the popover open — both windows restyle +- [ ] Every Settings toggle: launch at login, confirm before applying, warn on UTI conflicts, show bundle IDs, relaunch Finder, check automatically, channel, open-on-tab +- [ ] Set a default from the Extensions sheet; verify with `openwith current `; Undo from the toast; verify again +- [ ] Popover: extension lookup, change, Recent Changes + per-entry Undo +- [ ] Profiles: export; import via choose AND drag-drop; dry-run preview; apply; dismiss +- [ ] History panel scrolls at 50 entries and updates after changes +- [ ] Check Now (updates) reports a sensible result on both channels ### Release templates @@ -160,8 +176,8 @@ When releasing, update `version` in the root `Cargo.toml` (`workspace.package`) **Features** -- -- +- CLI/TUI: +- GUI: **Changes** - @@ -171,8 +187,8 @@ When releasing, update `version` in the root `Cargo.toml` (`workspace.package`) **Install** \```bash -brew tap ColeMei/openwith -brew install openwith +brew install ColeMei/openwith/openwith # CLI + TUI +brew install --cask ColeMei/openwith/openwith-gui # GUI app \``` ``` @@ -182,12 +198,14 @@ brew install openwith **Fixes** -- -- +- CLI/TUI: +- GUI: **Install** \```bash -brew tap ColeMei/openwith -brew install openwith +brew install ColeMei/openwith/openwith # CLI + TUI +brew install --cask ColeMei/openwith/openwith-gui # GUI app \``` ``` + +Group bullets under CLI/TUI and GUI prefixes when a release touches both; drop the prefix when a release is single-surface. diff --git a/README.md b/README.md index f92faf9..778b9ca 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,15 @@ ## Install -Homebrew is the recommended install path: +OpenWith ships in two flavors that share the same engine and change history — install either, or both: + +- **`openwith` (formula)** — the `openwith` command: CLI plus interactive TUI. Pick this if you live in the terminal or want to script/dotfile your associations. +- **`openwith-gui` (cask)** — OpenWith.app: a native windowed app with a menu-bar popover. Pick this if you'd rather point and click. + +### CLI / TUI ```bash -brew tap ColeMei/openwith -brew install openwith +brew install ColeMei/openwith/openwith ``` If you prefer installing from source with Cargo, install Rust via [rustup](https://rustup.rs), then run: @@ -47,13 +51,12 @@ cargo install --path crates/openwith-cli ### GUI app -The native GUI installs as a Homebrew cask (from v0.5.0): - ```bash -brew tap ColeMei/openwith -brew install --cask openwith +brew install --cask ColeMei/openwith/openwith-gui ``` +Or download the `.dmg` from the [latest release](https://github.com/ColeMei/openwith/releases). + The app is currently unsigned (no Apple Developer ID), so on first launch macOS will warn about an unidentified developer — right-click the app → Open, or clear the quarantine flag: @@ -64,6 +67,8 @@ xattr -dr com.apple.quarantine /Applications/OpenWith.app To build it from source instead: `cd crates/openwith-gui && npm install && npm run tauri build`. +> Installed the cask as `openwith` (pre-v0.5.2)? It was renamed: `brew uninstall --cask openwith && brew install --cask ColeMei/openwith/openwith-gui`. + ## Quick Start ```bash From 1f409a8d7a23ea769c893dcc1da3addc6a65c77d Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 17:11:33 +1000 Subject: [PATCH 5/6] release: bump version to 0.5.2 --- Cargo.lock | 6 +++--- Cargo.toml | 2 +- crates/openwith-gui/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b1cccc..3f269a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2624,7 +2624,7 @@ dependencies = [ [[package]] name = "openwith-cli" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "clap", @@ -2640,7 +2640,7 @@ dependencies = [ [[package]] name = "openwith-core" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "core-foundation", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "openwith-gui" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "openwith-core", diff --git a/Cargo.toml b/Cargo.toml index 2013b6d..453c337 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/openwith-core", "crates/openwith-cli", "crates/openwith-gui/src-tauri"] [workspace.package] -version = "0.5.1" +version = "0.5.2" edition = "2024" license = "MIT" repository = "https://github.com/ColeMei/openwith" diff --git a/crates/openwith-gui/package.json b/crates/openwith-gui/package.json index 591b8af..c45036f 100644 --- a/crates/openwith-gui/package.json +++ b/crates/openwith-gui/package.json @@ -1,7 +1,7 @@ { "name": "openwith-gui", "private": true, - "version": "0.5.1", + "version": "0.5.2", "type": "module", "scripts": { "dev": "vite", From 4d394ac8bde670c2ed0f83ba13c26725ee7492ca Mon Sep 17 00:00:00 2001 From: Cole Mei Date: Fri, 10 Jul 2026 17:28:22 +1000 Subject: [PATCH 6/6] fix: keep the main window visible when hiding the Dock icon Switching the activation policy to Accessory deactivates the app and orders its windows out, so the toggle appeared to close the main window. Re-show and refocus it after the switch when it was visible. --- crates/openwith-gui/src-tauri/src/commands.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/openwith-gui/src-tauri/src/commands.rs b/crates/openwith-gui/src-tauri/src/commands.rs index 9cf4409..ac2445b 100644 --- a/crates/openwith-gui/src-tauri/src/commands.rs +++ b/crates/openwith-gui/src-tauri/src/commands.rs @@ -312,7 +312,20 @@ pub fn set_dock_visible(app: AppHandle, visible: bool) -> Result<(), String> { } else { tauri::ActivationPolicy::Accessory }; - app.set_activation_policy(policy).map_err(|e| e.to_string()) + // Switching Regular → Accessory deactivates the app and orders its + // windows out, so the toggle would appear to close the main window. + // Re-show and refocus it if it was visible before the switch. + let main_was_visible = app + .get_webview_window("main") + .and_then(|w| w.is_visible().ok()) + .unwrap_or(false); + app.set_activation_policy(policy) + .map_err(|e| e.to_string())?; + if main_was_visible && let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + Ok(()) } #[derive(Serialize)]