| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "log/slog" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | ) |
| 12 | |
| 13 | const sessionPresetsFileName = "desktop-session-presets.json" |
| 14 | |
| 15 | type sessionPresetsFile struct { |
| 16 | Version int `json:"version"` |
| 17 | Sessions map[string]string `json:"sessions"` |
| 18 | } |
| 19 | |
| 20 | // sessionPresetStore holds the permission preset the user explicitly chose for |
| 21 | // each canonical session, keyed by SessionID. Only record writes to it, and only |
| 22 | // the explicit preset-change entry points call record; a session is restored |
| 23 | // to its own record or, without one, to the new-session default. |
| 24 | type sessionPresetStore struct { |
| 25 | mu sync.Mutex |
| 26 | loaded bool |
| 27 | unsaved bool |
| 28 | recorded map[string]string |
| 29 | } |
| 30 | |
| 31 | func newSessionPresetStore() *sessionPresetStore { |
| 32 | return &sessionPresetStore{} |
| 33 | } |
| 34 | |
| 35 | func sessionPresetsPath() string { |
| 36 | return filepath.Join(desktopConfigDir(), sessionPresetsFileName) |
| 37 | } |
| 38 | |
| 39 | func (s *sessionPresetStore) loadLocked() { |
| 40 | if s.loaded { |
| 41 | return |
| 42 | } |
| 43 | s.loaded = true |
| 44 | s.recorded = map[string]string{} |
| 45 | body, err := os.ReadFile(sessionPresetsPath()) |
| 46 | if errors.Is(err, os.ErrNotExist) { |
| 47 | return |
| 48 | } |
| 49 | var file sessionPresetsFile |
| 50 | if err == nil { |
| 51 | err = json.Unmarshal(body, &file) |
| 52 | } |
| 53 | if err != nil { |
| 54 | // Unreadable records restore nothing: every session opens at the default. |
| 55 | slog.Warn("desktop: session permission presets unreadable", "err", err) |
| 56 | return |
| 57 | } |
| 58 | for id, preset := range file.Sessions { |
| 59 | if id = strings.TrimSpace(id); id != "" { |
| 60 | s.recorded[id] = normalizeToolApprovalMode(preset) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // restore returns the preset recorded for sessionID, or fallback when the |
| 66 | // session has none. |
| 67 | func (s *sessionPresetStore) restore(sessionID, fallback string) string { |
| 68 | sessionID = strings.TrimSpace(sessionID) |
| 69 | fallback = normalizeToolApprovalMode(fallback) |
| 70 | if s == nil || sessionID == "" { |
| 71 | return fallback |
| 72 | } |
| 73 | s.mu.Lock() |
| 74 | defer s.mu.Unlock() |
| 75 | s.loadLocked() |
| 76 | if preset, ok := s.recorded[sessionID]; ok { |
| 77 | return preset |
| 78 | } |
| 79 | return fallback |
| 80 | } |
| 81 | |
| 82 | // record stores an explicit user choice for the session it was made in. |
| 83 | func (s *sessionPresetStore) record(sessionID, preset string) { |
| 84 | sessionID = strings.TrimSpace(sessionID) |
| 85 | if s == nil || sessionID == "" { |
| 86 | return |
| 87 | } |
| 88 | preset = normalizeToolApprovalMode(preset) |
| 89 | s.mu.Lock() |
| 90 | defer s.mu.Unlock() |
| 91 | s.loadLocked() |
| 92 | if recorded, ok := s.recorded[sessionID]; ok && recorded == preset && !s.unsaved { |
| 93 | return |
| 94 | } |
| 95 | s.recorded[sessionID] = preset |
| 96 | s.writeLocked() |
| 97 | } |
| 98 | |
| 99 | func (s *sessionPresetStore) forget(sessionID string) { |
| 100 | sessionID = strings.TrimSpace(sessionID) |
| 101 | if s == nil || sessionID == "" { |
| 102 | return |
| 103 | } |
| 104 | s.mu.Lock() |
| 105 | defer s.mu.Unlock() |
| 106 | s.loadLocked() |
| 107 | if _, ok := s.recorded[sessionID]; !ok && !s.unsaved { |
| 108 | return |
| 109 | } |
| 110 | delete(s.recorded, sessionID) |
| 111 | s.writeLocked() |
| 112 | } |
| 113 | |
| 114 | func (s *sessionPresetStore) writeLocked() { |
| 115 | body, err := json.MarshalIndent(sessionPresetsFile{Version: 1, Sessions: s.recorded}, "", " ") |
| 116 | if err == nil { |
| 117 | err = os.MkdirAll(desktopConfigDir(), 0o700) |
| 118 | } |
| 119 | if err == nil { |
| 120 | err = writeFileAtomic(sessionPresetsPath(), body, 0o600) |
| 121 | } |
| 122 | // The file must not go on holding a wider preset than this process runs |
| 123 | // with: a failed write drops or empties it, so a restart opens at the |
| 124 | // default, and the next record or forget writes the full set again. |
| 125 | s.unsaved = err != nil |
| 126 | if err != nil { |
| 127 | slog.Warn("desktop: session permission presets not saved", "err", err) |
| 128 | discardSessionPresetsFile() |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // discardSessionPresetsFile removes the file, or empties it in place when its |
| 133 | // directory refuses the removal; an empty file restores nothing. |
| 134 | func discardSessionPresetsFile() { |
| 135 | path := sessionPresetsPath() |
| 136 | rmErr := os.Remove(path) |
| 137 | if rmErr == nil || errors.Is(rmErr, os.ErrNotExist) { |
| 138 | return |
| 139 | } |
| 140 | if err := os.Truncate(path, 0); err != nil { |
| 141 | slog.Warn("desktop: stale session permission presets not discarded", "remove", rmErr, "truncate", err) |
| 142 | } |
| 143 | } |
| 144 |