Skip to content

fix(desktop): restore tray-only window behavior - #4023

Open
mauropereiira wants to merge 1 commit into
Automattic:masterfrom
mauropereiira:fix/window-lifecycle
Open

fix(desktop): restore tray-only window behavior#4023
mauropereiira wants to merge 1 commit into
Automattic:masterfrom
mauropereiira:fix/window-lifecycle

Conversation

@mauropereiira

Copy link
Copy Markdown
Contributor

This PR was implemented by an AI agent working interactively under @mauropereiira's direction. The diff and test results were reviewed before submission.

Issues

Fixes #3705.

This also restores close handling that was removed unintentionally while window and tray code were reorganized in #3779.

Description

Harper previously intercepted close requests for Editor and Settings, prevented destruction, and hid those windows. After #3779, the red close button destroys the window instead. The main process also remains a regular macOS application while tray-only, leaving a permanent Dock icon.

This change:

  • Restores CloseRequested -> prevent_close -> hide for Editor and Settings.
  • Switches macOS to Accessory activation policy when Harper starts tray-only.
  • Switches to Regular before showing Editor or Settings.
  • Returns to Accessory after the final visible user window is hidden.
  • Keeps Regular while another Harper window is visible or minimized.
  • Unminimizes existing windows before showing and focusing them from the tray.
  • Leaves the yellow minimize button's native Dock behavior unchanged.
  • Leaves tray Quit and Command-Q on Tauri's normal exit path.

No Dock preference, single-instance plugin, reopen handler, or tray refresh changes are included.

Demo

The built macOS app reported Regular activation policy (0) while Settings was visible and Accessory (1) after a tray-only relaunch. The parent and highlighter processes remained active in tray-only mode.

How Has This Been Tested?

  • cargo check -p harper-desktop --all-targets
  • cargo clippy -p harper-desktop --all-targets -- -D warnings
  • cargo test -p harper-desktop --lib (52 passed)
  • just format
  • just check-desktop on a combined branch containing all three desktop fixes
  • Built and launched an Apple Silicon .app bundle with Tauri
  • Manually verified Regular policy with Settings visible and Accessory policy on tray-only startup

The native red-close and yellow-minimize interactions still need a final hands-on pass because synthetic clicks are blocked by macOS Accessibility permissions in the test shell.

AI Disclosure

  • I am a human and didn't use any AI.
  • I used LLM features of my editor, but not an agent.
  • I consulted one or more coding AIs, but didn't use an agent.
  • I used an AI agent interactively.
  • I am an agent or I got an agent to do the work autonomously.

If Your PR Implements or Enhances a Linter

Not applicable.

Checklist

  • I have performed a self-review of my own code
  • I have added tests to cover my changes
  • I have considered splitting this into smaller pull requests.

@elijah-potter

Copy link
Copy Markdown
Collaborator

You've adjusted the behavior so that "closing" a window simply hides it. Can you articulate why this behavior is desired?

I only ask because I personally prefer my windows to fully close. This is especially true since the WebViews these windows occupy are pretty heavy.

@mauropereiira

Copy link
Copy Markdown
Contributor Author

Might be from what I'm used to? The behavior I was aiming for is the usual menu-bar app distinction where the red button dismisses Harper’s window and returns it to menu-bar-only mode, while the yellow button minimizes the window and keeps it in the Dock. Harper’s background service continues running either way. Plus quit from the tray or Command-Q still exits the app.

Also, while doing this PR I found that there might also be a practical reason to keep the editor window alive right now. Its text is held in the WebView and isn’t persisted elsewhere, so destroying the window means reopening an empty editor. Hiding it preserves the current document. This was also Harper’s behavior before #3779, so the PR restores that! 😬

I'm not the best coder (not close to it), so I asked the agent that did this and it said:

You’re right about the cost of retaining the WebViews, though. Settings doesn’t have the same unsaved-document concern, so a reasonable compromise would be to hide the editor but fully close Settings. Longer term, persisting the editor state would let us destroy that WebView too without losing work.

@AshwanthramKL

Copy link
Copy Markdown

AI disclosure: This local validation was performed by an AI agent under @AshwanthramKL's direction.

I tested commit 2be4c436 on Apple Silicon running macOS 26.5.2 (25F84).

Automated checks:

  • just format produced no changes.
  • just check-desktop passed.
  • cargo clippy -p harper-desktop --all-targets -- -D warnings passed.
  • cargo test -p harper-desktop --lib passed all 52 tests.
  • The universal x86_64 + arm64 binary, .app, and DMG were produced successfully. The recipe returned nonzero only when creating the signed updater artifact because an outside contributor does not have TAURI_SIGNING_PRIVATE_KEY.

Manual lifecycle checks against the locally built app:

  • Tray-only startup registered as type="UIElement" and did not require a Dock presence.
  • Opening Settings switched the process to type="Foreground".
  • Command-W removed the window, left the parent process alive, and returned it to type="UIElement".
  • Minimizing Settings left the process alive and type="Foreground", matching the PR description.
  • Command-Q exited the process normally.

One data point supporting @elijah-potter's WebView concern: the main process was about 320 MB RSS with Settings visible and about 334 MB after Command-W hid it, so the hidden WebView remained resident.

A narrower lifecycle variant may therefore be worth considering: keep the Accessory/Regular transitions, allow user windows to be destroyed on close, and switch back to Accessory on the final user-window Destroyed event. That would address #3705 without changing close semantics or retaining Settings. I have not implemented or tested that alternative.

I could not exercise reopening Editor/Settings through the tray with the available UI automation, and I did not grant the unsigned local bundle a new Accessibility permission, so those two paths remain unverified by this pass.

@mauropereiira

Copy link
Copy Markdown
Contributor Author

Thanks for running this properly. The universal build and the lifecycle matrix are more than I managed locally, and the two paths you couldn't reach are the same two I flagged.

The memory point lands. I went looking at where the two windows actually keep their state, and it splits them.

Settings commits every change as it happens rather than behind a Save button, so nothing saved is at risk (GeneralPage.svelte):

await Client.setDialect(settingsValueToDialect(value));
await Client.setLaunchAtStartup(enabled);
await Client.setAutoUpdate(enabled);

The most you'd lose by destroying it is a half-typed field like newDictionaryWord or newBundleId.

The Editor persists nothing at all. Editor.svelte takes export let content = '', EditorView.svelte mounts it with only {linter}, and there's no localStorage or Rust-side buffer behind it. Destroying that window drops whatever the user typed and reopens empty.

So I don't think it's one decision. Destroying Settings is cheap and frees the WebView you measured, since Settings is the window in your numbers. Destroying the Editor is silent data loss until something persists the buffer.

@elijah-potter, would a split work for you? Destroy Settings on close, keep hide-on-close for the Editor only, and return to Accessory when the last user window goes away whichever way it went. You'd get full close on the window you'd actually notice, #3705 stays fixed, and the Editor keeps its text until it has somewhere to put it.

Mechanically that's close to what you sketched. has_visible_or_minimized_user_window already copes with a destroyed window, since get_webview_window returns None once it's gone, so the missing piece is hooking the same check to Destroyed, because hide_user_window won't run on that path.

One note on the numbers for whoever picks this up: 320 MB visible against 334 MB hidden shows hiding doesn't reclaim, which is the part that matters, but it doesn't size what's being retained. Tray-only baseline against after-open-then-hide would, and that'd tell us whether destroying Settings buys real memory or is just tidier.

Happy to implement the split here if there's appetite, and I can take another pass at the tray reopen path with Accessibility granted to a signed local build.

@hippietrail

Copy link
Copy Markdown
Collaborator

Is a factor in this the usual macOS behaviour for apps with no windows open?

I know for "normal" apps, including even the system "Settings" app that closing the last window does not quit the app, so it still appears in the dock and alt+tab.

I've only been on Mac since the M1 and I mostly use it for coding and watching YouTube so I don't have a good feel for what "less normal" apps do. I'm aware there is a Mac concept of a "menu app". And various dev environments seem to have an option for quitting the app when the last window closes, which makes me think there is a category of Mac apps which do behave this way.

Any of our long-term Mac-first people have a proper intuition for this kind of thing, or am I totally misreading this and barking up the wrong tree?

@mauropereiira

mauropereiira commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@hippietrail Right tree, I think. It actually settles it, just not the way I first went.

There are two categories and they're one Info.plist flag apart.

Regular apps (NSApplicationActivationPolicyRegular) get a Dock tile and show up in ⌘-Tab. Closing the last window doesn't quit, like you said. The hook is applicationShouldTerminateAfterLastWindowClosed:, and if the delegate doesn't implement it the app just keeps running. That toggle you've seen in dev environments is that returning YES, which is common for single window utilities.

Accessory apps (LSUIElement, aka NSApplicationActivationPolicyAccessory) have no Dock tile and don't appear in ⌘-Tab. They live in the status bar. That's the "menu app" idea you're thinking of.

Harper is the second one but moves between both. It boots Accessory in lib.rs, flips to Regular when you open Editor or Settings in windows.rs, then flips back once the last user window is gone. So a menu bar app that borrows Regular while a window is up.

The bit I got wrong: in the normal app case the window really does get destroyed. applicationShouldTerminateAfterLastWindowClosed: keeps the process alive, but AppKit still tears the window down. So from what I can tell the convention is destroy the window and keep the process, which is closer to what @elijah-potter and @AshwanthramKL were describing than to what this PR does. My bad.

Also #3705 was about the Dock icon, so that part was already working before I touched close behaviour.

What's left holding up hide on close is the Editor buffer. EditorView.svelte mounts <Editor {linter} /> with no content prop, and I couldn't find any localStorage or Rust side buffer behind it, so destroying that window drops whatever you'd typed. I think that's a gap on our side, nothing macOS dictates, so it probably shouldn't be setting close semantics for both windows.

Which puts it back on the split already on the table: destroy Settings on close, keep hide on close for the Editor until its buffer has somewhere to live, and go back to Accessory when the last user window disappears either way.

@hippietrail

Copy link
Copy Markdown
Collaborator

Accessory apps (LSUIElement, aka NSApplicationActivationPolicyAccessory) have no Dock tile and don't appear in ⌘-Tab. They live in the status bar. That's the "menu app" idea you're thinking of.

Not exactly. Apps can have different activation policies and may or may not have a menu item up in the top right. The two are orthogonal. I've played with activation policy a fair bit to add optional Mac GUI to console apps for instance. Harper does actually add a menu item though, which I usually don't do. (I had an idea for one once but it turns out you can use a text input control in a menu app.)

Harper is the second one but moves between both. It boots Accessory in lib.rs, flips to Regular when you open Editor or Settings in windows.rs, then flips back once the last user window is gone. So a menu bar app that borrows Regular while a window is up.

The bit I got wrong: in the normal app case the window really does get destroyed. applicationShouldTerminateAfterLastWindowClosed: keeps the process alive, but AppKit still tears the window down. So from what I can tell the convention is destroy the window and keep the process, which is closer to what @elijah-potter and @AshwanthramKL were describing than to what this PR does. My bad.

Don't forget that Harper Desktop is actually two apps! The first versions even had two dock icons. To work as a single app they need to communicate between each other. One is the highlighter. I haven't used the desktop app for a couple of months because when I quit the second app was lurking and hogging CPU in ways I couldn't quite put my finger on. But I'd see it when I alt-tabbed between apps and switched to Activity Monitor where it would then drop from high CPU to acceptable. I'm not sure if it still does this. I used to force-quit that second app after quitting the first app the normal way.

Also #3705 was about the Dock icon, so that part was already working before I touched close behaviour.

What's left holding up hide on close is the Editor buffer. EditorView.svelte mounts <Editor {linter} /> with no content prop, and I couldn't find any localStorage or Rust side buffer behind it, so destroying that window drops whatever you'd typed. I think that's a gap on our side, nothing macOS dictates, so it probably shouldn't be setting close semantics for both windows.

Which puts it back on the split already on the table: destroy Settings on close, keep hide on close for the Editor until its buffer has somewhere to live, and go back to Accessory when the last user window disappears either way.

Have a look at whether the "two apps" is a factor. When you're using an AI agent I have a hunch that they don't suspect this or can even "forget about it" because most apps don't have a "hidden twin" and AIs follow code patterns, most of which are for normal single-app apps.

@mauropereiira

Copy link
Copy Markdown
Contributor Author

Fair on both counts.

Activation policy and the menu bar item. You are right, I ran those together and they are orthogonal. The policy governs the Dock tile and ⌘-Tab, while the status item is a separate NSStatusItem. Harper happens to do both, which is what let me treat them as one thing.

The two apps. Also right, and I had missed it. Confirming from the code for anyone reading later:

let child = Command::new(std::env::current_exe()?)
    .arg("highlighter")
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .spawn()?;

Same binary, second process, dispatched by Some(Command::Highlighter { no_parent }) => run_highlighter(!no_parent). The child then builds its own winit event loop:

event_loop_builder
    .with_activation_policy(ActivationPolicy::Accessory)
    .with_default_menu(false);

So the single Dock icon today is the child pinning itself to Accessory and never moving. Two NSApplications, two independent activation policies.

Is it a factor for this PR? For the window lifecycle I do not think so. Editor and Settings both live in the Tauri process, has_visible_or_minimized_user_window only walks USER_WINDOW_LABELS there, and the child's policy is fixed and in another process, so the flipping in windows.rs cannot reach it.

Where it does matter is the memory argument, which is what actually drove the hide versus destroy discussion. @AshwanthramKL measured "the main process" at roughly 320 MB visible and 334 MB hidden. If that is the Tauri process, the highlighter is not in those numbers at all, so we have been reasoning about WebView retention from one of two processes. Worth measuring both before anyone trades unsaved editor text for memory.

On the lurking second app. The teardown chain exists and looks right. HighlighterService::drop calls worker.stop(), HighlighterWorker::drop calls stop(), and run_server_until_shutdown is followed by highlighter_process.terminate(), which does start_kill then wait. HighlighterProcess::drop fires start_kill on its own as a backstop.

So if your symptom is real, and I have no reason to doubt it, the open question is whether that chain runs on every exit path rather than anything being absent from it. Tray quit is app.exit(0), and any path that skips Rust destructors would leave the child alive. The child's own safety net is narrow: it only notices the parent is gone inside refresh_config, which exits when a config fetch fails, so a child nobody is asking to refresh has no reason to check.

That would fit the pattern you describe, including the CPU dropping when you switched to Activity Monitor, since an orphan polling accessibility with nothing to talk to is the shape that gets throttled once it loses focus. I have not reproduced it, so treat that as a reading of the code rather than a diagnosis.

Happy to build the app and check whether the child survives tray quit, ⌘Q, and a force-quit of the parent, then open a separate issue if it does. It feels like its own bug rather than something this PR should absorb.

On your hunch about agents: correct here. I traced windows.rs and lib.rs and never asked whether there was a second process.

@hippietrail

Copy link
Copy Markdown
Collaborator

The two apps. Also right, and I had missed it. Confirming from the code for anyone reading later:

let child = Command::new(std::env::current_exe()?)
    .arg("highlighter")
    .stdin(Stdio::piped())
    .stdout(Stdio::piped())
    .spawn()?;

Same binary, second process, dispatched by Some(Command::Highlighter { no_parent }) => run_highlighter(!no_parent). The child then builds its own winit event loop:

event_loop_builder
    .with_activation_policy(ActivationPolicy::Accessory)
    .with_default_menu(false);

Ah yes, I remember now. I actually played with this pattern after seeing how Harper did it and I ran into the same conundrums of how quitting either "half" could/should tell the other half to quit. I put that experiment aside rather than try to solve it.

So the single Dock icon today is the child pinning itself to Accessory and never moving. Two NSApplications, two independent activation policies.

On the lurking second app. The teardown chain exists and looks right. HighlighterService::drop calls worker.stop(), HighlighterWorker::drop calls stop(), and run_server_until_shutdown is followed by highlighter_process.terminate(), which does start_kill then wait. HighlighterProcess::drop fires start_kill on its own as a backstop.

So if your symptom is real, and I have no reason to doubt it, the open question is whether that chain runs on every exit path rather than anything being absent from it. Tray quit is app.exit(0), and any path that skips Rust destructors would leave the child alive. The child's own safety net is narrow: it only notices the parent is gone inside refresh_config, which exits when a config fetch fails, so a child nobody is asking to refresh has no reason to check.

Ah yes! I remember I think I was vibecoding my Mac experiment that "forked" a second app off and I saw that it had app.exit(0) and it set off my code-smell alarm.

I seem to recall that certain Mac APIs "never return" - probably the one that starts a runloop. Which makes it seem like that's the right way to exit. But then what's going to happen with resource dtors? I believe I put this to the coding AIs I was using and I think it found a better way to exit cleanly, which I no longer recall. It definitely pays to look at the vibecoded code when it starts getting important!

That would fit the pattern you describe, including the CPU dropping when you switched to Activity Monitor, since an orphan polling accessibility with nothing to talk to is the shape that gets throttled once it loses focus. I have not reproduced it, so treat that as a reading of the code rather than a diagnosis.

Happy to build the app and check whether the child survives tray quit, ⌘Q, and a force-quit of the parent, then open a separate issue if it does. It feels like its own bug rather than something this PR should absorb.

I think that's the right direction to explore!

On your hunch about agents: correct here. I traced windows.rs and lib.rs and never asked whether there was a second process.

It's fun that the AIs can learn from me sometimes as I learn from them (-:

@mauropereiira

Copy link
Copy Markdown
Contributor Author

Correcting myself on the teardown reading. I said the child "only notices the parent is gone inside refresh_config, which exits when a config fetch fails, so a child nobody is asking to refresh has no reason to check". That's wrong. refresh_config is on a timer in the child's own event loop:

const CONFIG_POLL_INTERVAL: Duration = Duration::from_secs(1);

fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
    ...
    if now.duration_since(self.last_config_poll) >= CONFIG_POLL_INTERVAL {
        self.refresh_config();
        self.last_config_poll = now;
    }

It fires every second whether or not anyone asks, and fetch_highlighter_config makes six round trips over the pipe to do it. With the parent gone, read_line returns zero bytes and the error path is std::process::exit(1).

So I built the desktop binary and measured it instead of reading further. An orphaned child with a closed stdin, which is what a dead parent leaves behind:

exited after 2.1s with code 1
failed to refresh highlighter config: server closed the protocol stream before responding

It cleans itself up. That means a skipped teardown chain on the Tauri side doesn't leave a highlighter running, and app.exit(0) isn't enough on its own to produce what you saw.

There is exactly one mode that never exits, and it's a flag rather than an exit path:

let refresh_config = move || {
    if !has_parent {
        return;
    }

--no-parent skips the liveness check entirely. Same test, same closed stdin:

STILL RUNNING after 40s, killed

The app never spawns it that way, HighlighterProcess::spawn passes only highlighter, so this can't come from quitting Harper normally. It's reachable by running the highlighter by hand, which is worth knowing about but is a different thing from your report.

One caveat on the method. I gave the child a closed stdin from the start rather than killing a live parent mid-session, so what I've measured is the poll noticing EOF, not a full session teardown. It's the same closure on the same timer either way, but I haven't watched a healthy child lose its parent.

So I don't think there's a separate orphan bug to open here, unless you can still reproduce it on a current build. If you can, the interesting question becomes why the one second poll wasn't running, which is a narrower thing to look for than a skipped destructor chain. I'm happy to do the stronger version with a harness that speaks the protocol and then dies, if you'd rather have that than my inference.

@elijah-potter, the split is still what this PR is waiting on: destroy Settings on close, keep hide-on-close for the Editor until its buffer has somewhere to live, and return to Accessory when the last user window goes away by either route.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Harper Desktop: Allow Users to Hide The Icon from Dock

4 participants