返回 DeepSeek-Reasonix
theme_store.go
根目录 / desktop / theme_store.go
1 package main
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12
13 "reasonix/internal/config"
14 "reasonix/internal/fileutil"
15 )
16
17 func themeStatePath() string {
18 return filepath.Join(config.MemoryUserDir(), themeStateFileName)
19 }
20
21 func themesRootDir() string {
22 return filepath.Join(config.MemoryUserDir(), themeDirName)
23 }
24
25 func themeDir(id string) string {
26 return filepath.Join(themesRootDir(), id)
27 }
28
29 func themeManifestPath(id string) string {
30 return filepath.Join(themeDir(id), themePackManifestName)
31 }
32
33 func loadThemeDesktopState() ThemeDesktopState {
34 path := themeStatePath()
35 data, err := os.ReadFile(path)
36 if err != nil {
37 return ThemeDesktopState{SchemaVersion: themeStateSchemaVer}
38 }
39 var st ThemeDesktopState
40 if err := json.Unmarshal(data, &st); err != nil {
41 return ThemeDesktopState{SchemaVersion: themeStateSchemaVer}
42 }
43 if st.SchemaVersion == 0 {
44 st.SchemaVersion = themeStateSchemaVerV1
45 }
46 st.ActiveThemeID = strings.TrimSpace(st.ActiveThemeID)
47 return st
48 }
49
50 func saveThemeDesktopState(st ThemeDesktopState) error {
51 st.SchemaVersion = themeStateSchemaVer
52 st.ActiveThemeID = strings.TrimSpace(st.ActiveThemeID)
53 // Hard rule for v2: never persist base style ids as the active pack.
54 if isBuiltinThemeID(st.ActiveThemeID) {
55 st.ActiveThemeID = ""
56 }
57 dir := filepath.Dir(themeStatePath())
58 if err := os.MkdirAll(dir, 0o755); err != nil {
59 return err
60 }
61 data, err := json.MarshalIndent(st, "", " ")
62 if err != nil {
63 return err
64 }
65 return writeFileAtomic(themeStatePath(), data, 0o644)
66 }
67
68 func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
69 // Use the shared Windows-safe atomic writer (AV/indexer lock retries +
70 // cross-device fallback) instead of a bare os.Rename.
71 return fileutil.AtomicWriteFile(path, data, mode)
72 }
73
74 func loadUserThemeManifest(id string) (*ThemePackManifest, error) {
75 id = strings.TrimSpace(id)
76 if !themePackIDRe.MatchString(id) {
77 return nil, fmt.Errorf("invalid theme id")
78 }
79 if isReservedThemeID(id) {
80 return nil, fmt.Errorf("built-in theme %q has no user directory", id)
81 }
82 data, err := os.ReadFile(themeManifestPath(id))
83 if err != nil {
84 return nil, err
85 }
86 return parseThemePackManifest(data)
87 }
88
89 func listUserThemeIDs() ([]string, error) {
90 root := themesRootDir()
91 entries, err := os.ReadDir(root)
92 if err != nil {
93 if os.IsNotExist(err) {
94 return nil, nil
95 }
96 return nil, err
97 }
98 var ids []string
99 for _, e := range entries {
100 if !e.IsDir() {
101 continue
102 }
103 id := e.Name()
104 if !themePackIDRe.MatchString(id) || isReservedThemeID(id) {
105 continue
106 }
107 if _, err := os.Stat(themeManifestPath(id)); err != nil {
108 continue
109 }
110 ids = append(ids, id)
111 }
112 return ids, nil
113 }
114
115 func userThemeExists(id string) bool {
116 _, err := os.Stat(themeManifestPath(id))
117 return err == nil
118 }
119
120 // publishThemeDir atomically replaces themes/<id> with the prepared staging directory.
121 // stagingDir must already contain a validated theme.json and optional scene images.
122 func publishThemeDir(id, stagingDir string, replace bool) error {
123 id = strings.TrimSpace(id)
124 if !themePackIDRe.MatchString(id) {
125 return fmt.Errorf("invalid theme id")
126 }
127 if isReservedThemeID(id) {
128 return fmt.Errorf("built-in themes cannot be overwritten")
129 }
130 dest := themeDir(id)
131 if userThemeExists(id) && !replace {
132 return fmt.Errorf("theme %q already exists (set replace to overwrite)", id)
133 }
134 root := themesRootDir()
135 if err := os.MkdirAll(root, 0o755); err != nil {
136 return err
137 }
138 // Final rename target: themes/<id>
139 // Strategy: write to themes/.staging-<id>-* then rename over destination.
140 parentStaging, err := os.MkdirTemp(root, ".staging-"+id+"-")
141 if err != nil {
142 return err
143 }
144 cleanupStaging := true
145 defer func() {
146 if cleanupStaging {
147 _ = os.RemoveAll(parentStaging)
148 }
149 }()
150
151 // Copy staging contents into parentStaging (re-validate containment).
152 if err := copyThemeTree(stagingDir, parentStaging); err != nil {
153 return err
154 }
155 // Verify manifest still parses after copy.
156 data, err := os.ReadFile(filepath.Join(parentStaging, themePackManifestName))
157 if err != nil {
158 return err
159 }
160 m, err := parseThemePackManifest(data)
161 if err != nil {
162 return err
163 }
164 if m.ID != id {
165 return fmt.Errorf("theme id mismatch: manifest %q vs directory %q", m.ID, id)
166 }
167 if m.Background != nil && m.Background.Image != "" {
168 imgPath := filepath.Join(parentStaging, m.Background.Image)
169 if err := validateThemeImageFile(imgPath); err != nil {
170 return err
171 }
172 }
173 if m.TaskBackground != nil && m.TaskBackground.Image != "" {
174 imgPath := filepath.Join(parentStaging, m.TaskBackground.Image)
175 if err := validateThemeImageFile(imgPath); err != nil {
176 return err
177 }
178 }
179
180 // Replace destination atomically: rename old aside, rename new in, remove old.
181 backup := ""
182 if _, err := os.Stat(dest); err == nil {
183 backup = dest + ".bak-" + randomThemeSuffix()
184 if err := os.Rename(dest, backup); err != nil {
185 return err
186 }
187 }
188 if err := os.Rename(parentStaging, dest); err != nil {
189 if backup != "" {
190 _ = os.Rename(backup, dest)
191 }
192 return err
193 }
194 cleanupStaging = false
195 if backup != "" {
196 _ = os.RemoveAll(backup)
197 }
198 return nil
199 }
200
201 func randomThemeSuffix() string {
202 // Short non-crypto suffix for backup dir names.
203 return fmt.Sprintf("%d", os.Getpid())
204 }
205
206 func copyThemeTree(src, dst string) error {
207 srcAbs, err := filepath.Abs(src)
208 if err != nil {
209 return err
210 }
211 return filepath.Walk(srcAbs, func(path string, info os.FileInfo, err error) error {
212 if err != nil {
213 return err
214 }
215 rel, err := filepath.Rel(srcAbs, path)
216 if err != nil {
217 return err
218 }
219 if rel == "." {
220 return nil
221 }
222 // Only allow root-level files (theme.json + up to two scene images).
223 if strings.Contains(rel, string(os.PathSeparator)) || strings.Contains(rel, "/") || strings.Contains(rel, "\\") {
224 return fmt.Errorf("theme package may only contain root-level files, found %q", rel)
225 }
226 if info.Mode()&os.ModeSymlink != 0 {
227 return fmt.Errorf("theme package must not contain symlinks")
228 }
229 if info.IsDir() {
230 return fmt.Errorf("theme package must not contain subdirectories")
231 }
232 target := filepath.Join(dst, rel)
233 // Ensure target stays inside dst.
234 if !pathIsInside(dst, target) {
235 return fmt.Errorf("theme path escapes destination")
236 }
237 return copyFileLimited(path, target, themePackMaxImageBytes+themePackMaxManifest)
238 })
239 }
240
241 func pathIsInside(root, target string) bool {
242 rootAbs, err := filepath.Abs(root)
243 if err != nil {
244 return false
245 }
246 targetAbs, err := filepath.Abs(target)
247 if err != nil {
248 return false
249 }
250 rel, err := filepath.Rel(rootAbs, targetAbs)
251 if err != nil {
252 return false
253 }
254 return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
255 }
256
257 func copyFileLimited(src, dst string, maxBytes int64) error {
258 in, err := os.Open(src)
259 if err != nil {
260 return err
261 }
262 defer in.Close()
263 info, err := in.Stat()
264 if err != nil {
265 return err
266 }
267 if info.Size() > maxBytes {
268 return fmt.Errorf("file too large: %s", filepath.Base(src))
269 }
270 out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
271 if err != nil {
272 return err
273 }
274 defer out.Close()
275 written, err := io.Copy(out, io.LimitReader(in, maxBytes+1))
276 if err != nil {
277 return err
278 }
279 if written > maxBytes {
280 return fmt.Errorf("file too large: %s", filepath.Base(src))
281 }
282 return out.Sync()
283 }
284
285 func deleteUserTheme(id string) error {
286 id = strings.TrimSpace(id)
287 if !themePackIDRe.MatchString(id) {
288 return fmt.Errorf("invalid theme id")
289 }
290 if isReservedThemeID(id) {
291 return fmt.Errorf("built-in themes cannot be deleted")
292 }
293 dest := themeDir(id)
294 if !pathIsInside(themesRootDir(), dest) {
295 return fmt.Errorf("refusing to delete path outside themes root")
296 }
297 if _, err := os.Stat(dest); err != nil {
298 if os.IsNotExist(err) {
299 return fmt.Errorf("theme %q not found", id)
300 }
301 return err
302 }
303 return os.RemoveAll(dest)
304 }
305
306 // resolveActiveThemeID returns a loadable official/user/plugin theme id, or
307 // empty. Base style ids are never active packs under schema v2. A plugin:
308 // pointer resolves only while its plugin is installed AND enabled; when it
309 // does not resolve, callers fall back to the base style but MUST preserve the
310 // pointer (reinstalling the plugin restores the theme).
311 func resolveActiveThemeID(st ThemeDesktopState) string {
312 id := strings.TrimSpace(st.ActiveThemeID)
313 if id == "" || isBuiltinThemeID(id) {
314 return ""
315 }
316 if isPluginThemeID(id) {
317 pluginName, themeID, ok := parsePluginThemeID(id)
318 if !ok {
319 return ""
320 }
321 if pt := findPluginTheme(pluginName, themeID); pt != nil {
322 return pt.id
323 }
324 return ""
325 }
326 if isOfficialThemeID(id) {
327 return id
328 }
329 if userThemeExists(id) {
330 // Quick re-validate; corrupt themes fall back to none (caller falls to base style).
331 if _, err := loadUserThemeManifest(id); err == nil {
332 return id
333 }
334 }
335 return ""
336 }
337
338 func themeFileDigest(path string) (string, error) {
339 f, err := os.Open(path)
340 if err != nil {
341 return "", err
342 }
343 defer f.Close()
344 h := sha256.New()
345 if _, err := io.Copy(h, io.LimitReader(f, themePackMaxImageBytes+1)); err != nil {
346 return "", err
347 }
348 return hex.EncodeToString(h.Sum(nil))[:16], nil
349 }
350
351 type themeStagingImage struct {
352 path string
353 bytes []byte
354 }
355
356 func writeThemeStaging(m *ThemePackManifest, imagePath string, imageBytes []byte, taskImages ...themeStagingImage) (staging string, err error) {
357 if err := validateThemePackManifest(m); err != nil {
358 return "", err
359 }
360 staging, err = os.MkdirTemp("", "reasonix-theme-stage-*")
361 if err != nil {
362 return "", err
363 }
364 cleanup := true
365 defer func() {
366 if cleanup {
367 _ = os.RemoveAll(staging)
368 }
369 }()
370
371 writeImage := func(imageName, imagePath string, imageBytes []byte, label string) error {
372 var data []byte
373 switch {
374 case len(imageBytes) > 0:
375 data = imageBytes
376 case imagePath != "":
377 data, err = os.ReadFile(imagePath)
378 if err != nil {
379 return fmt.Errorf("read %s image: %w", label, err)
380 }
381 default:
382 return fmt.Errorf("%s image data is required", label)
383 }
384 if int64(len(data)) > themePackMaxImageBytes {
385 return fmt.Errorf("%s image exceeds %d bytes", label, themePackMaxImageBytes)
386 }
387 imgDest := filepath.Join(staging, imageName)
388 if err := os.WriteFile(imgDest, data, 0o644); err != nil {
389 return err
390 }
391 if err := validateThemeImageFile(imgDest); err != nil {
392 return err
393 }
394 return nil
395 }
396
397 // Place scene images first so names and image content are re-validated.
398 if m.Background != nil && m.Background.Image != "" {
399 if err := writeImage(m.Background.Image, imagePath, imageBytes, "home background"); err != nil {
400 return "", err
401 }
402 }
403 if m.TaskBackground != nil && m.TaskBackground.Image != "" {
404 var task themeStagingImage
405 if len(taskImages) > 0 {
406 task = taskImages[0]
407 }
408 if err := writeImage(m.TaskBackground.Image, task.path, task.bytes, "task background"); err != nil {
409 return "", err
410 }
411 }
412
413 raw, err := json.MarshalIndent(m, "", " ")
414 if err != nil {
415 return "", err
416 }
417 if len(raw) > themePackMaxManifest {
418 return "", fmt.Errorf("theme manifest exceeds %d bytes", themePackMaxManifest)
419 }
420 if err := os.WriteFile(filepath.Join(staging, themePackManifestName), raw, 0o644); err != nil {
421 return "", err
422 }
423 cleanup = false
424 return staging, nil
425 }
426
427 func resolveThemeImageAbs(id, imageName string) (string, error) {
428 id = strings.TrimSpace(id)
429 imageName = filepath.Base(strings.TrimSpace(imageName))
430 if !themePackIDRe.MatchString(id) || isReservedThemeID(id) {
431 return "", fmt.Errorf("invalid theme id")
432 }
433 if !themePackImageRe.MatchString(imageName) {
434 return "", fmt.Errorf("invalid image name")
435 }
436 root := themeDir(id)
437 abs := filepath.Join(root, imageName)
438 if !pathIsInside(root, abs) {
439 return "", fmt.Errorf("image path escapes theme directory")
440 }
441 // Resolve symlinks and re-check containment (TOCTOU defense on serve).
442 resolved, err := filepath.EvalSymlinks(abs)
443 if err != nil {
444 // If the file doesn't exist yet, still return the intended path after containment check.
445 if os.IsNotExist(err) {
446 return abs, nil
447 }
448 return "", err
449 }
450 rootResolved, err := filepath.EvalSymlinks(root)
451 if err != nil {
452 rootResolved = root
453 }
454 if !pathIsInside(rootResolved, resolved) {
455 return "", fmt.Errorf("image path escapes theme directory")
456 }
457 return resolved, nil
458 }
459
459 lines GO