Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 27 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <ext>`).

- [ ] 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 <ext>`; 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

Expand All @@ -160,8 +176,8 @@ When releasing, update `version` in the root `Cargo.toml` (`workspace.package`)
<one-line summary of the theme of this release>

**Features**
- <new capability 1>
- <new capability 2>
- CLI/TUI: <new capability 1>
- GUI: <new capability 2>

**Changes**
- <notable behavior change or improvement>
Expand All @@ -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
\```
```

Expand All @@ -182,12 +198,14 @@ brew install openwith
<one-line summary>

**Fixes**
- <fix 1>
- <fix 2>
- CLI/TUI: <fix 1>
- GUI: <fix 2>

**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.
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
57 changes: 45 additions & 12 deletions crates/openwith-core/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -87,6 +90,8 @@ fn load(path: &Path) -> Vec<HistoryEvent> {
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);
}
Expand Down Expand Up @@ -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(),
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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();
}
Expand All @@ -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));
Expand All @@ -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();
}
Expand Down
2 changes: 1 addition & 1 deletion crates/openwith-gui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "openwith-gui",
"private": true,
"version": "0.5.1",
"version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
35 changes: 33 additions & 2 deletions crates/openwith-gui/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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,
Expand Down
24 changes: 19 additions & 5 deletions crates/openwith-gui/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
});
}
Loading
Loading