| 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 |