返回 DeepSeek-Reasonix
remote_prefs.go
根目录 / desktop / remote_prefs.go
1 package main
2
3 import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "maps"
8 "os"
9 "path/filepath"
10 "slices"
11 "strings"
12 "sync"
13
14 "reasonix/internal/config"
15 "reasonix/internal/fileutil"
16 )
17
18 var remotePrefsMu sync.Mutex
19
20 // remotePrefs is desktop-only remote UI state, stored beside the other desktop
21 // JSON prefs (desktop-workspaces.json, desktop-tabs.json). All fields are
22 // optional so an older file decodes cleanly.
23 type remotePrefs struct {
24 LastHostID string `json:"lastHostId,omitempty"`
25 LastWorkspaceByHost map[string]string `json:"lastWorkspaceByHost,omitempty"`
26 ExplorerTab string `json:"explorerTab,omitempty"`
27 SessionTitles map[string]string `json:"sessionTitles,omitempty"`
28 PinnedSessions []string `json:"pinnedSessions,omitempty"`
29 // CredentialProxySecret is the random root of the per-host virtual tokens
30 // used by local-proxy credential mode. Rotating it revokes every token.
31 CredentialProxySecret string `json:"credentialProxySecret,omitempty"`
32 }
33
34 func remotePrefsPath() string {
35 dir := config.MemoryUserDir()
36 if dir == "" {
37 return ""
38 }
39 return filepath.Join(dir, "desktop-remote.json")
40 }
41
42 func loadRemotePrefs() remotePrefs {
43 p := remotePrefs{
44 LastWorkspaceByHost: map[string]string{},
45 SessionTitles: map[string]string{},
46 }
47 path := remotePrefsPath()
48 if path == "" {
49 // Keep the "maps are always initialized" contract on this early
50 // return too: writers assign into them without a nil guard.
51 p.LastWorkspaceByHost = map[string]string{}
52 p.SessionTitles = map[string]string{}
53 return p
54 }
55 data, err := os.ReadFile(path)
56 if err != nil {
57 return p
58 }
59 _ = json.Unmarshal(data, &p)
60 if p.LastWorkspaceByHost == nil {
61 p.LastWorkspaceByHost = map[string]string{}
62 }
63 if p.SessionTitles == nil {
64 p.SessionTitles = map[string]string{}
65 }
66 return p
67 }
68
69 func remotePrefsForUpdateLocked() remotePrefs {
70 p := loadRemotePrefs()
71 p.LastWorkspaceByHost = cloneStringMap(p.LastWorkspaceByHost)
72 p.SessionTitles = cloneStringMap(p.SessionTitles)
73 p.PinnedSessions = append([]string(nil), p.PinnedSessions...)
74 return p
75 }
76
77 // remotePrefsSnapshot amortizes reads for one listing operation without
78 // retaining state across calls or desktop instances.
79 func remotePrefsSnapshot() remotePrefs {
80 remotePrefsMu.Lock()
81 defer remotePrefsMu.Unlock()
82 p := loadRemotePrefs()
83 p.LastWorkspaceByHost = cloneStringMap(p.LastWorkspaceByHost)
84 p.SessionTitles = cloneStringMap(p.SessionTitles)
85 p.PinnedSessions = append([]string(nil), p.PinnedSessions...)
86 return p
87 }
88
89 func cloneStringMap(src map[string]string) map[string]string {
90 dst := make(map[string]string, len(src))
91 maps.Copy(dst, src)
92 return dst
93 }
94
95 func remotePrefsSaveLocked(p remotePrefs) error {
96 if err := saveRemotePrefs(p); err != nil {
97 log.Printf("[remote] prefs: save FAILED err=%v", err)
98 return err
99 }
100 return nil
101 }
102
103 // updateRemotePrefsLocked serializes the full read-modify-write transaction
104 // with other Reasonix processes. Callers already hold remotePrefsMu, which
105 // provides the equivalent process-local ordering.
106 func updateRemotePrefsLocked(mutate func(*remotePrefs) (bool, error)) (remotePrefs, error) {
107 path := remotePrefsPath()
108 if strings.TrimSpace(path) == "" {
109 return remotePrefs{}, fmt.Errorf("remote prefs: no user dir")
110 }
111 unlock, err := config.LockConfigFileEdits(path)
112 if err != nil {
113 return remotePrefs{}, fmt.Errorf("remote prefs: lock: %w", err)
114 }
115 defer unlock()
116 p := remotePrefsForUpdateLocked()
117 changed, err := mutate(&p)
118 if err != nil {
119 return remotePrefs{}, err
120 }
121 if changed {
122 if err := remotePrefsSaveLocked(p); err != nil {
123 return remotePrefs{}, err
124 }
125 }
126 return p, nil
127 }
128
129 func remoteSessionPrefKey(hostID, workspace, name string) string {
130 return hostID + "\x00" + workspace + "\x00" + name
131 }
132
133 func remoteSessionTitleOverride(hostID, workspace, name string) string {
134 remotePrefsMu.Lock()
135 defer remotePrefsMu.Unlock()
136 return loadRemotePrefs().SessionTitles[remoteSessionPrefKey(hostID, workspace, name)]
137 }
138
139 func remoteSessionPinned(hostID, workspace, name string) bool {
140 remotePrefsMu.Lock()
141 defer remotePrefsMu.Unlock()
142 return remoteSessionPinnedLocked(loadRemotePrefs(), remoteSessionPrefKey(hostID, workspace, name))
143 }
144
145 func remoteSessionPinnedLocked(p remotePrefs, key string) bool {
146 return slices.Contains(p.PinnedSessions, key)
147 }
148
149 func setRemoteSessionPinned(hostID, workspace, name string, pinned bool) error {
150 remotePrefsMu.Lock()
151 defer remotePrefsMu.Unlock()
152 _, err := updateRemotePrefsLocked(func(p *remotePrefs) (bool, error) {
153 key := remoteSessionPrefKey(hostID, workspace, name)
154 if remoteSessionPinnedLocked(*p, key) == pinned {
155 return false, nil
156 }
157 next := make([]string, 0, len(p.PinnedSessions)+1)
158 for _, existing := range p.PinnedSessions {
159 if existing != key {
160 next = append(next, existing)
161 }
162 }
163 if pinned {
164 next = append(next, key)
165 }
166 p.PinnedSessions = next
167 return true, nil
168 })
169 return err
170 }
171
172 func setRemoteSessionTitleOverride(hostID, workspace, name, title string) error {
173 remotePrefsMu.Lock()
174 defer remotePrefsMu.Unlock()
175 _, err := updateRemotePrefsLocked(func(p *remotePrefs) (bool, error) {
176 key := remoteSessionPrefKey(hostID, workspace, name)
177 title = strings.TrimSpace(title)
178 if p.SessionTitles[key] == title {
179 return false, nil
180 }
181 if title == "" {
182 delete(p.SessionTitles, key)
183 } else {
184 p.SessionTitles[key] = title
185 }
186 return true, nil
187 })
188 return err
189 }
190
191 // migrateRemoteSessionTitleOverride moves preferences assigned to the
192 // synthetic blank row onto the durable Serve session name created by its first
193 // turn. Keeping title and pin migration in one locked update prevents a
194 // listing refresh from observing either preference under neither identity.
195 func migrateRemoteSessionTitleOverride(hostID, workspace, name string) (string, error) {
196 name = strings.TrimSpace(name)
197 if name == "" {
198 return "", nil
199 }
200 remotePrefsMu.Lock()
201 defer remotePrefsMu.Unlock()
202 named := ""
203 _, err := updateRemotePrefsLocked(func(p *remotePrefs) (bool, error) {
204 blankKey := remoteSessionPrefKey(hostID, workspace, "")
205 namedKey := remoteSessionPrefKey(hostID, workspace, name)
206 named = strings.TrimSpace(p.SessionTitles[namedKey])
207 blank := strings.TrimSpace(p.SessionTitles[blankKey])
208 changed := false
209 if named == "" && blank != "" {
210 named = blank
211 p.SessionTitles[namedKey] = blank
212 changed = true
213 }
214 if blank != "" {
215 delete(p.SessionTitles, blankKey)
216 changed = true
217 }
218 if remoteSessionPinnedLocked(*p, blankKey) {
219 next := make([]string, 0, len(p.PinnedSessions))
220 for _, key := range p.PinnedSessions {
221 if key != blankKey && key != namedKey {
222 next = append(next, key)
223 }
224 }
225 next = append(next, namedKey)
226 p.PinnedSessions = next
227 changed = true
228 }
229 return changed, nil
230 })
231 return named, err
232 }
233
234 func saveRemotePrefs(p remotePrefs) error {
235 path := remotePrefsPath()
236 if path == "" {
237 return fmt.Errorf("remote prefs: no user dir")
238 }
239 data, err := json.MarshalIndent(p, "", " ")
240 if err != nil {
241 return fmt.Errorf("remote prefs: encode: %w", err)
242 }
243 return fileutil.AtomicWriteFile(path, data, 0o600)
244 }
245
246 func (a *App) saveLastRemoteWorkspace(hostID, workspace string) error {
247 remotePrefsMu.Lock()
248 defer remotePrefsMu.Unlock()
249 _, err := updateRemotePrefsLocked(func(p *remotePrefs) (bool, error) {
250 if p.LastHostID == hostID && p.LastWorkspaceByHost[hostID] == workspace {
251 return false, nil
252 }
253 p.LastHostID = hostID
254 p.LastWorkspaceByHost[hostID] = workspace
255 return true, nil
256 })
257 return err
258 }
259
260 // RemoteLastWorkspace returns the last opened workspace for hostID (bound so
261 // the frontend can prefill the server card).
262 func (a *App) RemoteLastWorkspace(hostID string) string {
263 remotePrefsMu.Lock()
264 defer remotePrefsMu.Unlock()
265 return loadRemotePrefs().LastWorkspaceByHost[hostID]
266 }
267
267 lines GO