Skip to content

Commit 3b52907

Browse files
committed
fix: window state persistence and login-item toggle desync (v0.5.4)
1 parent 8a71012 commit 3b52907

10 files changed

Lines changed: 119 additions & 13 deletions

File tree

AUDIT_LOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
This log tracks all significant changes, updates, and versions in the PaperCache project.
44

5+
## 2026-06-25 - (Uncommitted)
6+
**Change:** fix: window state persistence and login-item toggle desync (v0.5.4)
7+
8+
**Details/Why:**
9+
Two bug fixes for window state and settings reliability:
10+
11+
1. **Window position/size not persisting across restarts**: The `tauri-plugin-window-state` v2.4.1's `on_window_ready` fires before the macOS display server is ready, causing `available_monitors()` to return empty and the saved position to be silently discarded. Fixed with a two-pronged approach: (a) `lib.rs:107-140` spawns a background thread that sleeps 300ms then dispatches `restore_state` + direct file read via `run_on_main_thread` — ensures display server is ready; (b) `commands/system.rs:33-73` new `restore_window_state` command reads `.window-state.json` directly and calls `set_position`/`set_size`, bypassing the plugin's intersection check as a fallback. Both tray "Quit" and Settings "Quit" now call `app.save_window_state()` explicitly before `app.exit(0)`.
12+
13+
2. **Launch-at-startup toggle desync with macOS System Settings**: The toggle only read from `localStorage`, so removing PaperCache from System Settings left it permanently stuck on. Fixed by adding `get_launch_at_startup` Tauri command (`system.rs:96-98`) that queries `app.autolaunch().is_enabled()`, bridged to frontend via `api.ts`/`types.d.ts`. `Settings.tsx:52-59` runs `getLaunchAtStartup()` on mount to sync toggle and `localStorage` with real OS state.
14+
15+
**Files changed:** `src-tauri/src/commands/system.rs`, `src-tauri/src/tray.rs`, `src-tauri/src/lib.rs`, `src/App.tsx:56`, `src/Settings.tsx:48-61`, `src/Settings.test.tsx`, `src/types.d.ts`, `src/api.ts`, `CHANGELOG.md`.
16+
17+
---
18+
519
## 2026-06-24 - (Uncommitted)
620
**Change:** feat: graph view rebuilt, Windows focus-loss fix, Cmd+/ shortcuts, welcome revamp (v0.5.3)
721

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable, user-facing changes to PaperCache will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [v0.5.4] - 2026-06-25
9+
10+
### Fixed
11+
- **Window position/size now persists across restarts**: The window-state plugin's `on_window_ready` fires before the macOS display server is ready, causing `available_monitors()` to return empty and the saved position to be silently discarded. Fixed by deferring window-state restoration via a background thread + `run_on_main_thread` 300ms after `setup()` completes, bypassing the plugin's monitor-intersection check with a direct file read. Both the tray "Quit" and Settings "Quit" buttons now explicitly save window state before exit.
12+
- **Login-item toggle stays in sync with macOS System Settings**: The launch-at-startup toggle only read from `localStorage`, so removing PaperCache from System Settings left the toggle permanently stuck in the checked state. Fixed by adding a `get_launch_at_startup` Tauri command that queries the actual OS login-item state via `app.autolaunch().is_enabled()`, and syncing the toggle with the real OS state on every Settings mount.
13+
814
## [v0.5.3] - 2026-06-24
915

1016
### Added

src-tauri/src/commands/system.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use tauri::{AppHandle, Manager, WebviewWindow};
22
use tauri_plugin_opener::OpenerExt;
3+
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
34

45
#[tauri::command]
56
pub fn close_window(window: WebviewWindow) -> Result<(), String> {
@@ -28,8 +29,44 @@ pub fn toggle_window(app: &AppHandle) {
2829
}
2930
}
3031

32+
#[tauri::command]
33+
pub fn restore_window_state(app: AppHandle) -> Result<(), String> {
34+
if let Some(window) = app.get_webview_window("main") {
35+
if let Ok(app_dir) = app.path().app_config_dir() {
36+
let state_path = app_dir.join(".window-state.json");
37+
if let Ok(content) = std::fs::read_to_string(&state_path) {
38+
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
39+
if let Some(main) = val.get("main") {
40+
if let (Some(x), Some(y)) = (
41+
main.get("x").and_then(|v| v.as_i64()),
42+
main.get("y").and_then(|v| v.as_i64()),
43+
) {
44+
let _ = window.set_position(tauri::PhysicalPosition::new(x as i32, y as i32));
45+
}
46+
if let (Some(w), Some(h)) = (
47+
main.get("width").and_then(|v| v.as_i64()),
48+
main.get("height").and_then(|v| v.as_i64()),
49+
) {
50+
let _ = window.set_size(tauri::PhysicalSize::new(w as u32, h as u32));
51+
}
52+
}
53+
}
54+
}
55+
}
56+
#[cfg(target_os = "macos")]
57+
{
58+
if let Ok(mut pos) = window.outer_position() {
59+
pos.y = pos.y.saturating_sub(28);
60+
let _ = window.set_position(tauri::Position::Physical(pos));
61+
}
62+
}
63+
}
64+
Ok(())
65+
}
66+
3167
#[tauri::command]
3268
pub fn quit_app(app: AppHandle) {
69+
let _ = app.save_window_state(StateFlags::POSITION | StateFlags::SIZE);
3370
app.exit(0);
3471
}
3572

@@ -64,6 +101,11 @@ pub fn open_file(app: AppHandle, path: String) -> Result<(), String> {
64101

65102
use tauri_plugin_autostart::ManagerExt;
66103

104+
#[tauri::command]
105+
pub fn get_launch_at_startup(app: AppHandle) -> Result<bool, String> {
106+
app.autolaunch().is_enabled().map_err(|e| e.to_string())
107+
}
108+
67109
#[tauri::command]
68110
pub fn set_launch_at_startup(app: AppHandle, enabled: bool) -> Result<(), String> {
69111
if enabled {

src-tauri/src/lib.rs

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use commands::shortcuts::GlobalShortcutState;
1212
use commands::notifications::NotificationState;
1313
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1414
use std::sync::Arc;
15+
use tauri_plugin_window_state::{StateFlags, WindowExt};
1516

1617
pub struct DialogState {
1718
pub is_open: Arc<AtomicBool>,
@@ -54,18 +55,6 @@ pub fn run() {
5455

5556
use tauri::Manager;
5657
if let Some(window) = app.get_webview_window("main") {
57-
#[cfg(target_os = "macos")]
58-
{
59-
crate::macos::set_move_to_active_space(&window);
60-
61-
// Fix for macOS frameless window walking down on restart
62-
// tauri-plugin-window-state restores position with titlebar offset
63-
if let Ok(mut pos) = window.outer_position() {
64-
pos.y = pos.y.saturating_sub(28);
65-
let _ = window.set_position(tauri::Position::Physical(pos));
66-
}
67-
}
68-
6958
let dialog_state = app.state::<crate::DialogState>();
7059
let is_dialog_open = dialog_state.is_open.clone();
7160
#[cfg(not(target_os = "macos"))]
@@ -110,6 +99,45 @@ pub fn run() {
11099
_ => {}
111100
}
112101
});
102+
103+
#[cfg(target_os = "macos")]
104+
crate::macos::set_move_to_active_space(&window);
105+
106+
// Restore window state after event loop is running and display server is ready.
107+
// Plugin's on_window_ready fires too early for available_monitors() on macOS.
108+
let win = window.clone();
109+
std::thread::spawn(move || {
110+
std::thread::sleep(std::time::Duration::from_millis(300));
111+
let _ = win.clone().run_on_main_thread(move || {
112+
let _ = win.restore_state(StateFlags::POSITION | StateFlags::SIZE);
113+
if let Ok(app_dir) = win.app_handle().path().app_config_dir() {
114+
let state_path = app_dir.join(".window-state.json");
115+
if let Ok(content) = std::fs::read_to_string(&state_path) {
116+
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
117+
if let Some(main) = val.get("main") {
118+
if let (Some(x), Some(y)) = (
119+
main.get("x").and_then(|v| v.as_i64()),
120+
main.get("y").and_then(|v| v.as_i64()),
121+
) {
122+
let _ = win.set_position(tauri::PhysicalPosition::new(x as i32, y as i32));
123+
}
124+
if let (Some(w), Some(h)) = (
125+
main.get("width").and_then(|v| v.as_i64()),
126+
main.get("height").and_then(|v| v.as_i64()),
127+
) {
128+
let _ = win.set_size(tauri::PhysicalSize::new(w as u32, h as u32));
129+
}
130+
}
131+
}
132+
}
133+
}
134+
#[cfg(target_os = "macos")]
135+
if let Ok(mut pos) = win.outer_position() {
136+
pos.y = pos.y.saturating_sub(28);
137+
let _ = win.set_position(tauri::Position::Physical(pos));
138+
}
139+
});
140+
});
113141
} else {
114142
eprintln!("WARNING: 'main' window not found during setup");
115143
}
@@ -132,9 +160,11 @@ pub fn run() {
132160
commands::fs::set_dialog_open,
133161
commands::fs::remove_onboarding_files,
134162
commands::system::close_window,
163+
commands::system::restore_window_state,
135164
commands::system::quit_app,
136165
commands::system::open_external,
137166
commands::system::open_file,
167+
commands::system::get_launch_at_startup,
138168
commands::system::set_launch_at_startup,
139169
commands::system::check_for_updates,
140170
commands::system::is_hyprland,

src-tauri/src/tray.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ pub fn create_tray(app: &App) -> Result<(), Box<dyn std::error::Error>> {
2828
if event.id == "show_hide" {
2929
crate::commands::system::toggle_window(app);
3030
} else if event.id == "quit" {
31-
app.exit(0);
31+
crate::commands::system::quit_app(app.clone());
3232
}
3333
})
3434
.on_tray_icon_event(|tray, event| {

src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function App() {
5353

5454
useEffect(() => {
5555
window.electronAPI.checkForUpdates()
56+
window.electronAPI.restoreWindowState()
5657
window.electronAPI.isHyprland().then((isHyp) => {
5758
useAppStore.getState().setIsHyprland(isHyp)
5859
})

src/Settings.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ describe('Settings Component', () => {
88
// Mock the electronAPI
99
window.electronAPI = {
1010
...window.electronAPI,
11+
getLaunchAtStartup: vi.fn().mockResolvedValue(false),
1112
setLaunchAtStartup: vi.fn(),
1213
updateGlobalShortcut: vi.fn(),
1314
closeWindow: vi.fn(),

src/Settings.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ export default function Settings({ onClose }: { onClose?: () => void }) {
5050
localStorage.getItem(SETTINGS_KEYS.LAUNCH_STARTUP) === 'true'
5151
)
5252

53+
// Sync launch-at-startup toggle with actual OS state on mount
54+
useEffect(() => {
55+
window.electronAPI.getLaunchAtStartup().then((enabled) => {
56+
setLaunchAtStartup(enabled)
57+
localStorage.setItem(SETTINGS_KEYS.LAUNCH_STARTUP, enabled.toString())
58+
})
59+
}, [])
60+
5361
// Appearance State
5462
const initialSettings = useSettingsStore.getState()
5563
const [fontFamily, setFontFamily] = useState(initialSettings.fontFamily)

src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ export const tauriApi: ElectronAPI = {
3333
setApiKey: (key) => invoke('set_api_key', { key }),
3434
getApiKeyStatus: () => invoke('get_api_key_status'),
3535
checkForUpdates: () => invoke('check_for_updates'),
36+
restoreWindowState: () => invoke('restore_window_state'),
3637
isHyprland: () => invoke('is_hyprland'),
3738
onSwipeGesture: () => {
3839
return () => {}
3940
},
41+
getLaunchAtStartup: () => invoke('get_launch_at_startup'),
4042
setLaunchAtStartup: (value) => {
4143
invoke('set_launch_at_startup', { enabled: value })
4244
},

src/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface ElectronAPI {
1818
setApiKey: (key: string) => Promise<boolean>
1919
getApiKeyStatus: () => Promise<boolean>
2020
checkForUpdates: () => Promise<void>
21+
restoreWindowState: () => Promise<void>
2122
isHyprland: () => Promise<boolean>
2223
readNote: (id: string) => Promise<string>
2324
exportNote: (filename: string, content: string) => Promise<boolean>
@@ -32,6 +33,7 @@ export interface ElectronAPI {
3233
openExternal: (url: string) => void
3334
openFile: (path: string) => void
3435
onSwipeGesture: (callback: (direction: string) => void) => () => void
36+
getLaunchAtStartup: () => Promise<boolean>
3537
setLaunchAtStartup: (value: boolean) => void
3638
updateGlobalShortcut: (action: string, oldShortcut: string, newShortcut: string) => void
3739
onTriggerNewNote: (callback: () => void) => () => void

0 commit comments

Comments
 (0)