返回 DeepSeek-Reasonix
window_state.go
根目录 / desktop / window_state.go
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sync"
9
10 "reasonix/internal/config"
11 "reasonix/internal/fileutil"
12 )
13
14 // DesktopWindowState captures the window geometry to restore across launches.
15 type DesktopWindowState struct {
16 Width int `json:"width"`
17 Height int `json:"height"`
18 X int `json:"x"`
19 Y int `json:"y"`
20 Maximised bool `json:"maximised"`
21 }
22
23 const (
24 // Minimum geometry accepted from the frontend (mirrors Wails MinWidth/MinHeight
25 // floor with a slightly looser lower bound so older saved states still restore).
26 minWindowWidth = 400
27 minWindowHeight = 300
28 // maxWindowDimension rejects corrupt or absurd sizes without relying on live
29 // monitor queries during save/shutdown.
30 maxWindowDimension = 100_000
31 // When a monitor is unplugged the saved origin may sit well outside the
32 // remaining virtual desktop. Only reject absurd values here; the shell
33 // checks visibility against actual display origins and work areas.
34 maxWindowOriginAbs = 100_000
35 minWindowOrigin = -maxWindowOriginAbs
36 )
37
38 var (
39 windowStateMu sync.Mutex
40 windowStatePersistMu sync.Mutex
41 lastKnownWindow DesktopWindowState
42 lastKnownWindowOK bool
43 )
44
45 func windowStatePath() string {
46 return filepath.Join(config.MemoryUserDir(), "desktop-window.json")
47 }
48
49 // loadWindowState reads the saved window geometry. The second return value is
50 // false when no saved state exists (first launch, missing file, corrupt JSON,
51 // or out-of-range dimensions). Callers must not restore position when ok is
52 // false — zero values are not a valid window origin.
53 func loadWindowState() (DesktopWindowState, bool) {
54 path := windowStatePath()
55 data, err := readFileUTF8(path)
56 if err != nil {
57 return DesktopWindowState{}, false
58 }
59 state, err := parseWindowStateJSON(data)
60 if err != nil {
61 return DesktopWindowState{}, false
62 }
63 // Seed the process-local last-known-good so background-hide and shutdown
64 // can persist without querying the native window (which can panic when DPI
65 // reports 0 during Wails teardown).
66 rememberWindowState(state)
67 return state, true
68 }
69
70 // parseWindowStateJSON validates a desktop-window.json payload.
71 func parseWindowStateJSON(data []byte) (DesktopWindowState, error) {
72 var s DesktopWindowState
73 if err := json.Unmarshal(data, &s); err != nil {
74 return DesktopWindowState{}, fmt.Errorf("decode window state: %w", err)
75 }
76 if err := validateWindowState(s); err != nil {
77 return DesktopWindowState{}, err
78 }
79 return s, nil
80 }
81
82 // validateWindowState rejects sizes/positions that must never be written back.
83 // x=-8,y=-8 is intentionally valid: Windows border metrics can land there.
84 func validateWindowState(s DesktopWindowState) error {
85 if s.Width < minWindowWidth || s.Width > maxWindowDimension {
86 return fmt.Errorf("window width %d out of range [%d, %d]", s.Width, minWindowWidth, maxWindowDimension)
87 }
88 if s.Height < minWindowHeight || s.Height > maxWindowDimension {
89 return fmt.Errorf("window height %d out of range [%d, %d]", s.Height, minWindowHeight, maxWindowDimension)
90 }
91 if s.X < minWindowOrigin || s.X > maxWindowOriginAbs {
92 return fmt.Errorf("window x %d out of range [%d, %d]", s.X, minWindowOrigin, maxWindowOriginAbs)
93 }
94 if s.Y < minWindowOrigin || s.Y > maxWindowOriginAbs {
95 return fmt.Errorf("window y %d out of range [%d, %d]", s.Y, minWindowOrigin, maxWindowOriginAbs)
96 }
97 return nil
98 }
99
100 func rememberWindowState(s DesktopWindowState) {
101 windowStateMu.Lock()
102 defer windowStateMu.Unlock()
103 lastKnownWindow = s
104 lastKnownWindowOK = true
105 }
106
107 func lastKnownWindowState() (DesktopWindowState, bool) {
108 windowStateMu.Lock()
109 defer windowStateMu.Unlock()
110 if !lastKnownWindowOK {
111 return DesktopWindowState{}, false
112 }
113 return lastKnownWindow, true
114 }
115
116 // resetLastKnownWindowStateForTest clears the process-local cache. Tests only.
117 func resetLastKnownWindowStateForTest() {
118 windowStateMu.Lock()
119 defer windowStateMu.Unlock()
120 lastKnownWindow = DesktopWindowState{}
121 lastKnownWindowOK = false
122 }
123
124 // SaveWindowState is the bound method the frontend calls to persist the current
125 // window geometry before quit and periodically during use. Go never queries the
126 // native window for geometry; only frontend-reported values are accepted.
127 func (a *App) SaveWindowState(state DesktopWindowState) error {
128 if err := validateWindowState(state); err != nil {
129 return err
130 }
131 windowStatePersistMu.Lock()
132 defer windowStatePersistMu.Unlock()
133 rememberWindowState(state)
134 return writeWindowState(state)
135 }
136
137 func writeWindowState(state DesktopWindowState) error {
138 path := windowStatePath()
139 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
140 return err
141 }
142 data, err := json.Marshal(state)
143 if err != nil {
144 return err
145 }
146 return fileutil.AtomicWriteFile(path, data, 0o644)
147 }
148
149 // saveWindowStateSync re-persists the last frontend-reported geometry. It must
150 // never call WindowGetSize / WindowGetPosition / WindowIsMaximised: during
151 // Wails shutdown those paths can hit ScaleToDefaultDPI with DPI=0 and panic.
152 // If no frontend report has landed yet, this is a no-op (first-launch quit).
153 func (a *App) saveWindowStateSync() {
154 windowStatePersistMu.Lock()
155 defer windowStatePersistMu.Unlock()
156 state, ok := lastKnownWindowState()
157 if !ok {
158 return
159 }
160 if err := writeWindowState(state); err != nil {
161 // Best-effort: the frontend already wrote this state earlier.
162 _ = err
163 }
164 }
165
166 // lastKnownMaximised returns the last frontend-reported maximised flag for
167 // background-hide restore. Falls back to false when nothing was reported.
168 func (a *App) lastKnownMaximised() bool {
169 state, ok := lastKnownWindowState()
170 if !ok {
171 return false
172 }
173 return state.Maximised
174 }
175
175 lines GO