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/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/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 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(); } 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", diff --git a/crates/openwith-gui/src-tauri/src/commands.rs b/crates/openwith-gui/src-tauri/src/commands.rs index a1cdc96..ac2445b 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,31 @@ 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 + }; + // 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)] 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) { 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();