fix(desktop): restore tray-only window behavior - #4023
Conversation
|
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. |
|
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:
|
I tested commit Automated checks:
Manual lifecycle checks against the locally built app:
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 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. |
|
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 ( The most you'd lose by destroying it is a half-typed field like The Editor persists nothing at all. 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. 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. |
|
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? |
|
@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 ( Accessory apps ( Harper is the second one but moves between both. It boots The bit I got wrong: in the normal app case the window really does get destroyed. 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. 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 |
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.)
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.
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. |
|
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 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 event_loop_builder
.with_activation_policy(ActivationPolicy::Accessory)
.with_default_menu(false);So the single Dock icon today is the child pinning itself to 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, 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. 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 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 |
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.
Ah yes! I remember I think I was vibecoding my Mac experiment that "forked" a second app off and I saw that it had 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!
I think that's the right direction to explore!
It's fun that the AIs can learn from me sometimes as I learn from them (-: |
|
Correcting myself on the teardown reading. I said the child "only notices the parent is gone inside 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 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: It cleans itself up. That means a skipped teardown chain on the Tauri side doesn't leave a highlighter running, and 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;
}
The app never spawns it that way, 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 |
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:
CloseRequested -> prevent_close -> hidefor Editor and Settings.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-targetscargo clippy -p harper-desktop --all-targets -- -D warningscargo test -p harper-desktop --lib(52 passed)just formatjust check-desktopon a combined branch containing all three desktop fixes.appbundle with TauriThe 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
If Your PR Implements or Enhances a Linter
Not applicable.
Checklist