返回 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 // Windows frameless/bordered windows often report a small negative origin
32 // (commonly -8,-8) when docked to the primary display edge. Treat those as
33 // legitimate positions rather than "off-screen" corruption.
34 minWindowOrigin = -100
35 // When a monitor is unplugged the saved origin may sit well outside the
36 // remaining virtual desktop. Positions beyond this soft bound are rejected
37 // at restore time so the window is re-centered.
38 maxWindowOriginAbs = 100_000
39 )
40
41 var (
42 windowStateMu sync.Mutex
43 windowStatePersistMu sync.Mutex
44 lastKnownWindow DesktopWindowState
45 lastKnownWindowOK bool
46 )
47
48 func windowStatePath() string {
49 return filepath.Join(config.MemoryUserDir(), "desktop-window.json")
50 }
51
52 // loadWindowState reads the saved window geometry. The second return value is
53 // false when no saved state exists (first launch, missing file, corrupt JSON,
54 // or out-of-range dimensions). Callers must not restore position when ok is
55 // false — zero values are not a valid window origin.
56 func loadWindowState() (DesktopWindowState, bool) {
57 path := windowStatePath()
58 data, err := readFileUTF8(path)
59 if err != nil {
60 return DesktopWindowState{}, false
61 }
62 state, err := parseWindowStateJSON(data)
63 if err != nil {
64 return DesktopWindowState{}, false
65 }
66 // Seed the process-local last-known-good so background-hide and shutdown
67 // can persist without querying the native window (which can panic when DPI
68 // reports 0 during Wails teardown).
69 rememberWindowState(state)
70 return state, true
71 }
72
73 // parseWindowStateJSON validates a desktop-window.json payload.
74 func parseWindowStateJSON(data []byte) (DesktopWindowState, error) {
75 var s DesktopWindowState
76 if err := json.Unmarshal(data, &s); err != nil {
77 return DesktopWindowState{}, fmt.Errorf("decode window state: %w", err)
78 }
79 if err := validateWindowState(s); err != nil {
80 return DesktopWindowState{}, err
81 }
82 return s, nil
83 }
84
85 // validateWindowState rejects sizes/positions that must never be written back.
86 // x=-8,y=-8 is intentionally valid: Windows border metrics can land there.
87 func validateWindowState(s DesktopWindowState) error {
88 if s.Width < minWindowWidth || s.Width > maxWindowDimension {
89 return fmt.Errorf("window width %d out of range [%d, %d]", s.Width, minWindowWidth, maxWindowDimension)
90 }
91 if s.Height < minWindowHeight || s.Height > maxWindowDimension {
92 return fmt.Errorf("window height %d out of range [%d, %d]", s.Height, minWindowHeight, maxWindowDimension)
93 }
94 if s.X < minWindowOrigin || s.X > maxWindowOriginAbs {
95 return fmt.Errorf("window x %d out of range [%d, %d]", s.X, minWindowOrigin, maxWindowOriginAbs)
96 }
97 if s.Y < minWindowOrigin || s.Y > maxWindowOriginAbs {
98 return fmt.Errorf("window y %d out of range [%d, %d]", s.Y, minWindowOrigin, maxWindowOriginAbs)
99 }
100 return nil
101 }
102
103 // windowPositionRestorable reports whether a saved origin is safe to apply.
104 // Slightly negative coordinates (Windows border insets) are accepted; large
105 // off-screen positions force a center fallback.
106 func windowPositionRestorable(s DesktopWindowState, maxScreenW, maxScreenH int) bool {
107 if s.X < minWindowOrigin || s.Y < minWindowOrigin {
108 return false
109 }
110 if maxScreenW > 0 && s.X > maxScreenW*2 {
111 return false
112 }
113 if maxScreenH > 0 && s.Y > maxScreenH*2 {
114 return false
115 }
116 return true
117 }
118
119 func rememberWindowState(s DesktopWindowState) {
120 windowStateMu.Lock()
121 defer windowStateMu.Unlock()
122 lastKnownWindow = s
123 lastKnownWindowOK = true
124 }
125
126 func lastKnownWindowState() (DesktopWindowState, bool) {
127 windowStateMu.Lock()
128 defer windowStateMu.Unlock()
129 if !lastKnownWindowOK {
130 return DesktopWindowState{}, false
131 }
132 return lastKnownWindow, true
133 }
134
135 // resetLastKnownWindowStateForTest clears the process-local cache. Tests only.
136 func resetLastKnownWindowStateForTest() {
137 windowStateMu.Lock()
138 defer windowStateMu.Unlock()
139 lastKnownWindow = DesktopWindowState{}
140 lastKnownWindowOK = false
141 }
142
143 // SaveWindowState is the bound method the frontend calls to persist the current
144 // window geometry before quit and periodically during use. Go never queries the
145 // native window for geometry; only frontend-reported values are accepted.
146 func (a *App) SaveWindowState(state DesktopWindowState) error {
147 if err := validateWindowState(state); err != nil {
148 return err
149 }
150 windowStatePersistMu.Lock()
151 defer windowStatePersistMu.Unlock()
152 rememberWindowState(state)
153 return writeWindowState(state)
154 }
155
156 func writeWindowState(state DesktopWindowState) error {
157 path := windowStatePath()
158 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
159 return err
160 }
161 data, err := json.Marshal(state)
162 if err != nil {
163 return err
164 }
165 return fileutil.AtomicWriteFile(path, data, 0o644)
166 }
167
168 // saveWindowStateSync re-persists the last frontend-reported geometry. It must
169 // never call WindowGetSize / WindowGetPosition / WindowIsMaximised: during
170 // Wails shutdown those paths can hit ScaleToDefaultDPI with DPI=0 and panic.
171 // If no frontend report has landed yet, this is a no-op (first-launch quit).
172 func (a *App) saveWindowStateSync() {
173 windowStatePersistMu.Lock()
174 defer windowStatePersistMu.Unlock()
175 state, ok := lastKnownWindowState()
176 if !ok {
177 return
178 }
179 if err := writeWindowState(state); err != nil {
180 // Best-effort: the frontend already wrote this state earlier.
181 _ = err
182 }
183 }
184
185 // lastKnownMaximised returns the last frontend-reported maximised flag for
186 // background-hide restore. Falls back to false when nothing was reported.
187 func (a *App) lastKnownMaximised() bool {
188 state, ok := lastKnownWindowState()
189 if !ok {
190 return false
191 }
192 return state.Maximised
193 }
194
194 lines GO