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
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Desktop: Fix macOS Window Lifecycle & Crash Resilience

**Product:** `tdn-desktop/`
**GitHub Issue:** #48

## Background

Closing the Desktop app's main window kills the entire process, making it impossible to reopen via the dock icon or use the quick pane shortcut. Additionally, the app crashes when macOS Screen Time blocks it, during overnight App Nap suspension, or when the user dismisses Screen Time restrictions ("Ignore for today"). These are likely related — the window lifecycle is wrong, and the file watcher has no resilience to process suspension.

## Phased Approach

### Phase 1: Fix macOS window close behavior (Issue #48) — DONE

**Goal:** Closing the main window hides it instead of quitting. Dock icon click reopens it. Quick pane works independently. Cmd+Q and Quit menu item still quit properly.

**What was implemented in `src-tauri/src/lib.rs`:**

1. **Intercept `CloseRequested` for the main window on macOS:**
- Call `api.prevent_close()` so the window isn't destroyed
- Call `window.hide()` on the main window (NOT `app_handle.hide()` — see below)
- Call `save_window_state()` to persist position/size before hiding

2. **Hide the window, not the app:**
- We use `window.hide()` (hides only the main window) rather than `app_handle.hide()` (which calls `NSApplication.hide()` and sets the system-level hidden state)
- This is critical because showing an NSPanel while the app is in the system "hidden" state causes macOS to unhide the entire app, including the main window
- With `window.hide()`, the app is still "running" but not "hidden" — the quick pane can be shown independently without the main window reappearing
- Cmd+H still works normally (it calls `NSApplication.hide()` at the system level, and interacting with the app after Cmd+H naturally brings everything back — standard macOS behavior)

3. **Add `RunEvent::Reopen` handler:**
- When the dock icon is clicked and there are no visible windows, show the main window
- Explicitly call `window.restore_state(StateFlags::all())` after showing — the `tauri-plugin-window-state` plugin only auto-restores on app startup, not after a hide/show cycle. Without this, the window could appear at stale/off-screen coordinates and jump when dragged.
- Focus the window after restoring

4. **Move cleanup to `RunEvent::Exit`:**
- `hide_quick_pane()` and `unregister_global_shortcuts()` now run in `RunEvent::Exit` instead of on window close
- This ensures cleanup happens on actual quit (Cmd+Q, menu Quit) regardless of how the quit was initiated
- These cleanup functions exist to prevent known crashes during app teardown — removing them causes crashes on quit
- `RunEvent::Exit` fires reliably before the process exits, unlike `RunEvent::ExitRequested` which doesn't fire for Cmd+Q on macOS (tauri-apps/tauri#9198)
- We do NOT use `prevent_exit()` anywhere, which avoids the infinite `windowDidMove` loop issue with `tauri_plugin_window_state` (tauri-apps/tauri discussions#11489)

5. **Non-macOS behavior unchanged:** On other platforms, closing the main window still quits the app after running cleanup.

**Key references:**
- tauri-apps/tauri#3084 — `RunEvent::Reopen` feature
- tauri-apps/tauri PR#4865 — implementation
- tauri-apps/tauri#9198 — `ExitRequested` unreliable on macOS
- tauri-apps/tauri#13511 — `prevent_exit()` blocks normal termination
- tauri-apps/tauri discussions#11489 — `window-state` + `prevent_exit()` infinite loop
- plugins-workspace#1546 — quick-pane NSPanel denylisted from window-state plugin

**Behavior summary:**

| Action | Result |
|--------|--------|
| Red X (close button) | Main window hides. App stays running. Quick pane shortcut works. |
| Dock icon click (window hidden) | Main window shows at saved position. |
| Quick pane shortcut (window hidden) | Only the quick pane appears. Main window stays hidden. |
| Cmd+H | macOS hides entire app (system-level). Standard behavior. |
| Dock icon click (after Cmd+H) | macOS unhides the app. Main window reappears. |
| Quick pane shortcut (after Cmd+H) | macOS unhides entire app. Both main window and quick pane appear. |
| Cmd+Q / menu Quit | Cleanup runs via `RunEvent::Exit`, then app exits. |

### Phase 2: File watcher error recovery & periodic rescan

**Goal:** The vault file watcher recovers from crashes and a periodic rescan catches missed changes.

**Changes in `src-tauri/src/vault/manager.rs`:**

- Add error handling in the debouncer callback — detect when the watcher has died and trigger a rebuild (drop old watcher, full rescan, create new watcher)
- Add a periodic vault rescan timer (e.g. every 5 minutes) as a safety net. This is the Syncthing pattern and what Apple recommends for mission-critical apps. It should be lightweight — compare file mtimes against the in-memory index
- Handle the `Rescan` event kind from notify (maps to `kFSEventStreamEventFlagMustScanSubDirs`) — trigger a full directory scan when this fires, as it means events were coalesced or dropped

**Why this matters:** When Screen Time SIGSTOPs the process or App Nap suspends it, FSEvents queue up in `fseventsd`. On resume they arrive all at once, possibly coalesced. The debouncer may not handle this burst, and if the watcher thread panics the app has no recovery path today.

### Phase 3: Upgrade notify dependencies

**Goal:** Pick up recent FSEvents crash fixes.

- Upgrade `notify` and `notify-debouncer-full` to latest versions
- notify 9.0.0-rc.1/rc.2 include: preventing panics in the FSEvents callback, fixing stream start errors, fixing empty path crashes, making StreamContextInfo Send
- Test thoroughly after upgrade — the rc versions may have breaking API changes

**References:**
- notify-rs/notify CHANGELOG
- notify-rs/notify#283 (watcher panic on suspend/resume)

### Phase 4: App Nap and sleep/wake handling (optional)

**Goal:** Prevent aggressive App Nap suspension and rebuild the watcher after system sleep.

This phase may not be necessary if Phases 1-3 resolve the overnight crashes. Evaluate after those are done.

- **App Nap prevention:** Use `NSProcessInfo.beginActivityWithOptions:reason:` with `NSActivityUserInitiated` to prevent macOS from aggressively suspending the app while the file watcher is active
- **Sleep/wake detection:** Listen for `NSWorkspaceDidWakeNotification` and rebuild the file watcher on wake
- Both require `objc2` crate calls from Rust

**References:**
- Apple QA1340: Sleep/Wake Notifications
- Electron issue electron/electron#973 (App Nap)
4 changes: 2 additions & 2 deletions tdn-desktop/src-tauri/Cargo.lock

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

4 changes: 2 additions & 2 deletions tdn-desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description = "A desktop application for managing tasks"
authors = ["dannysmith"]
license = "AGPL-3.0-only"
edition = "2021"
rust-version = "1.82"
rust-version = "1.85"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

Expand Down Expand Up @@ -45,7 +45,7 @@ rayon = "1" # Parallel file scanning
globset = "0.4" # Ignore patterns
uuid = { version = "1", features = ["v4"] } # Unique ID generation
xxhash-rust = { version = "0.8", features = ["xxh64"] } # Stable cross-version hashing
notify-debouncer-full = "0.5" # File watching with debouncing
notify-debouncer-full = "0.7" # File watching with debouncing
Comment thread
coderabbitai[bot] marked this conversation as resolved.
parking_lot = "0.12" # Better RwLock implementation
trash = "5" # Cross-platform trash/recycle bin

Expand Down
116 changes: 93 additions & 23 deletions tdn-desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ pub mod vault;
mod apple_intelligence;

use std::error::Error;
use tauri::{App, AppHandle, Manager, RunEvent, WindowEvent};
use std::time::Duration;
use tauri::{App, AppHandle, Emitter, Manager, RunEvent, WindowEvent};
use vault::VaultManager;

// Re-export only what's needed externally
Expand Down Expand Up @@ -220,31 +221,100 @@ fn setup_vault(app: &mut App) {
} else {
log::info!("Vault not configured - user needs to set directory paths in preferences");
}

// Start periodic rescan as a safety net for missed file watcher events
start_periodic_rescan(app.handle().clone());
}

/// Handle application run events, particularly window close cleanup.
fn handle_run_event(app_handle: &AppHandle, event: RunEvent) {
if let RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { .. },
..
} = &event
{
if label == "main" {
handle_main_window_close(app_handle);
/// Interval between periodic vault rescans.
const RESCAN_INTERVAL: Duration = Duration::from_secs(5 * 60);

/// Periodically rescan the vault to catch any changes the file watcher may have missed.
///
/// This handles cases where the watcher dies silently, events are lost during
/// macOS App Nap / Screen Time suspension, or FSEvents coalesces events.
fn start_periodic_rescan(app_handle: AppHandle) {
std::thread::spawn(move || {
loop {
std::thread::sleep(RESCAN_INTERVAL);

let vault_manager = app_handle.state::<VaultManager>();
if !vault_manager.is_configured() {
continue;
}

log::debug!("Periodic vault rescan running");
match vault_manager.refresh() {
Ok(()) => {
if let Err(e) = app_handle.emit(vault::VAULT_CHANGED_EVENT, ()) {
log::error!("Failed to emit vault-changed event after rescan: {e}");
}
}
Err(e) => {
log::warn!("Periodic vault rescan failed: {e:?}");
}
}
}
}
});
}

/// Perform cleanup when the main window is closed.
fn handle_main_window_close(app_handle: &AppHandle) {
log::info!("Main window close requested - performing cleanup");

save_window_state(app_handle);
hide_quick_pane(app_handle);
unregister_global_shortcuts(app_handle);
/// Handle application run events.
///
/// On macOS, closing the main window hides the app instead of quitting,
/// following standard macOS behavior. The app can be reopened via the dock icon.
/// On other platforms, closing the main window quits the app normally.
fn handle_run_event(app_handle: &AppHandle, event: RunEvent) {
match &event {
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { api, .. },
..
} if label == "main" => {
log::info!("Main window close requested");
save_window_state(app_handle);

// On macOS, hide the main window instead of closing — standard macOS behavior.
// We hide the window (not the app) so the quick pane can be shown independently
// without triggering a system-level app unhide. Cmd+H still hides the whole app
// via NSApplication.hide() as normal. Cmd+Q and the Quit menu item bypass
// CloseRequested entirely, so they still quit the app normally.
#[cfg(target_os = "macos")]
{
api.prevent_close();
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
}

log::info!("Cleanup complete, allowing close to proceed");
#[cfg(not(target_os = "macos"))]
{ _ = api; }
}
RunEvent::Reopen {
has_visible_windows,
..
} => {
if !*has_visible_windows {
log::info!("App reopen requested - showing main window");
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
// Restore saved position/size — the window-state plugin only
// auto-restores on startup, not after a hide/show cycle.
#[cfg(desktop)]
{
use tauri_plugin_window_state::{StateFlags, WindowExt};
let _ = window.restore_state(StateFlags::all());
}
let _ = window.set_focus();
}
}
}
RunEvent::Exit => {
log::info!("Application exiting - performing cleanup");
hide_quick_pane(app_handle);
unregister_global_shortcuts(app_handle);
}
_ => {}
}
}

/// Save window state before closing.
Expand All @@ -262,21 +332,21 @@ fn save_window_state(app_handle: &AppHandle) {
#[cfg(not(desktop))]
fn save_window_state(_app_handle: &AppHandle) {}

/// Hide the quick-pane panel before main window closes.
/// Hide the quick-pane panel during app cleanup.
#[cfg(target_os = "macos")]
fn hide_quick_pane(app_handle: &AppHandle) {
use tauri_nspanel::ManagerExt;

if let Ok(panel) = app_handle.get_webview_panel("quick-pane") {
log::debug!("Hiding quick-pane panel before close");
log::debug!("Hiding quick-pane panel");
panel.hide();
}
}

#[cfg(not(target_os = "macos"))]
fn hide_quick_pane(_app_handle: &AppHandle) {}

/// Unregister all global shortcuts.
/// Unregister all global shortcuts during app cleanup.
#[cfg(desktop)]
fn unregister_global_shortcuts(app_handle: &AppHandle) {
use tauri_plugin_global_shortcut::GlobalShortcutExt;
Expand Down
12 changes: 10 additions & 2 deletions tdn-desktop/src-tauri/src/vault/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use crate::vault::{
};

/// Event emitted when vault data changes (for frontend cache invalidation)
const VAULT_CHANGED_EVENT: &str = "vault-changed";
pub const VAULT_CHANGED_EVENT: &str = "vault-changed";

/// Debounce interval for file watcher
const DEBOUNCE_DURATION: Duration = Duration::from_millis(100);
Expand Down Expand Up @@ -264,9 +264,17 @@ impl VaultManager {
}
}
Err(errors) => {
for e in errors {
for e in &errors {
warn!("File watcher error: {e}");
}
// Emit vault-changed so the frontend triggers a refresh,
// picking up any events the watcher may have missed.
if !errors.is_empty() {
warn!("Triggering vault refresh due to watcher errors");
if let Err(e) = app_handle.emit(VAULT_CHANGED_EVENT, ()) {
error!("Failed to emit vault-changed event after watcher error: {e}");
}
}
}
}
})
Expand Down
2 changes: 1 addition & 1 deletion tdn-desktop/src-tauri/src/vault/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub use entities::{
ProjectUpdate, Task, TaskStatus, TaskUpdate,
};
pub use error::VaultError;
pub use manager::{VaultIndex, VaultManager};
pub use manager::{VaultIndex, VaultManager, VAULT_CHANGED_EVENT};
pub use scanner::{
parse_area_file, parse_project_file, parse_task_file, scan_areas, scan_projects, scan_tasks,
VaultConfig,
Expand Down