返回 DeepSeek-Reasonix
theme_app.go
根目录 / desktop / theme_app.go
1 package main
2
3 import (
4 "encoding/base64"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10
11 "reasonix/internal/config"
12 )
13
14 // themeMu serializes theme library mutations (import/save/delete/activate).
15 var themeMu sync.Mutex
16
17 // stagedThemeImport holds a ZIP extract awaiting replace confirmation.
18 // Host paths stay on the Go side — the frontend only sees pendingId.
19 type stagedThemeImport struct {
20 id string
21 staging string
22 pack ThemePackView
23 }
24
25 var (
26 pendingThemeMu sync.Mutex
27 pendingThemeStage *stagedThemeImport
28 )
29
30 func clearPendingThemeImport() {
31 pendingThemeMu.Lock()
32 defer pendingThemeMu.Unlock()
33 if pendingThemeStage != nil && pendingThemeStage.staging != "" {
34 _ = os.RemoveAll(pendingThemeStage.staging)
35 }
36 pendingThemeStage = nil
37 }
38
39 func setPendingThemeImport(id, staging string, pack ThemePackView) string {
40 pendingThemeMu.Lock()
41 if pendingThemeStage != nil && pendingThemeStage.staging != "" && pendingThemeStage.staging != staging {
42 _ = os.RemoveAll(pendingThemeStage.staging)
43 }
44 pendingID := "pending-" + id + "-" + randomThemeSuffix()
45 pendingThemeStage = &stagedThemeImport{id: id, staging: staging, pack: pack}
46 pendingThemeMu.Unlock()
47 return pendingID
48 }
49
50 func takePendingThemeImport() *stagedThemeImport {
51 pendingThemeMu.Lock()
52 defer pendingThemeMu.Unlock()
53 p := pendingThemeStage
54 pendingThemeStage = nil
55 return p
56 }
57
58 // ListThemePacks returns base directions, official themes and user themes.
59 // Base packs are never "active" as theme packs; their "active" flag means
60 // "this is the configured base style and no pack is applied".
61 func (a *App) ListThemePacks() ([]ThemePackView, error) {
62 themeMu.Lock()
63 defer themeMu.Unlock()
64
65 st := a.migrateThemeDesktopStateLocked()
66 activeID := resolveActiveThemeID(st)
67 baseStyle := a.desktopBaseStyleLocked()
68
69 var out []ThemePackView
70 for _, m := range builtinThemePacks() {
71 cp := m
72 // Base "active" = no pack applied and this is the configured base style.
73 baseActive := activeID == "" && baseStyle == m.ID
74 out = append(out, manifestToView(&cp, themeKindBase, baseActive, "", ""))
75 }
76 for _, ot := range officialThemes() {
77 m := ot.manifest
78 bgURL := officialAssetURL(m.ID, m.Background.Image)
79 pvURL := officialAssetURL(m.ID, officialPreviewName)
80 out = append(out, manifestToView(&m, themeKindOfficial, activeID == m.ID, bgURL, pvURL))
81 }
82 ids, err := listUserThemeIDs()
83 if err != nil {
84 return out, err
85 }
86 for _, id := range ids {
87 m, err := loadUserThemeManifest(id)
88 if err != nil {
89 continue
90 }
91 bgURL := ""
92 if m.Background != nil && m.Background.Image != "" {
93 bgURL = themeBackgroundURL(id, m.Background.Image)
94 }
95 taskURL := ""
96 if m.TaskBackground != nil && m.TaskBackground.Image != "" {
97 taskURL = themeBackgroundURL(id, m.TaskBackground.Image)
98 }
99 out = append(out, manifestToView(m, themeKindUser, activeID == id, bgURL, "", taskURL))
100 }
101 // Plugin themes come last: read-only, resolved live from enabled plugins.
102 pluginThemes, _ := discoverPluginThemes()
103 for _, pt := range pluginThemes {
104 out = append(out, pluginThemeView(pt, activeID == pt.id))
105 }
106 return out, nil
107 }
108
109 // GetActiveThemePack returns the currently enabled pack (nil pack when none).
110 func (a *App) GetActiveThemePack() (ThemeActiveView, error) {
111 themeMu.Lock()
112 defer themeMu.Unlock()
113
114 view := ThemeActiveView{}
115 st := a.migrateThemeDesktopStateLocked()
116 activeID := resolveActiveThemeID(st)
117 if st.ActiveThemeID != "" && activeID == "" {
118 // Plugin theme pointer whose plugin is missing/disabled/uninstalled:
119 // fall back to the base style but PRESERVE the pointer on disk so
120 // reinstalling the same plugin restores the theme.
121 if isPluginThemeID(st.ActiveThemeID) {
122 return view, nil
123 }
124 // Broken or migrated-away pointer: clear so the next launch is clean.
125 st.ActiveThemeID = ""
126 _ = saveThemeDesktopState(st)
127 return view, nil
128 }
129 if activeID == "" {
130 return view, nil
131 }
132 view.ActiveThemeID = activeID
133 pack, err := a.loadThemeViewLocked(activeID, true)
134 if err != nil {
135 view.ActiveThemeID = ""
136 if isPluginThemeID(activeID) {
137 // Lost the race with a plugin change: same preserve contract.
138 return view, nil
139 }
140 st.ActiveThemeID = ""
141 _ = saveThemeDesktopState(st)
142 return view, nil
143 }
144 view.Pack = &pack
145 return view, nil
146 }
147
148 // GetThemeExperience returns the unified appearance state for overview + gallery.
149 func (a *App) GetThemeExperience() (ThemeExperienceView, error) {
150 themeMu.Lock()
151 defer themeMu.Unlock()
152
153 // Migrate first so a v1 base-style activeThemeId lands in desktop.theme_style
154 // before we read appearance.
155 st := a.migrateThemeDesktopStateLocked()
156 themeMode, baseStyle := a.desktopAppearanceLocked()
157 _, themeWarnings := discoverPluginThemes()
158 view := ThemeExperienceView{
159 ThemeMode: themeMode,
160 BaseStyle: baseStyle,
161 EffectiveStyle: baseStyle,
162 Warnings: themeWarnings,
163 }
164 activeID := resolveActiveThemeID(st)
165 if st.ActiveThemeID != "" && activeID == "" {
166 // Plugin theme pointer with a missing/disabled plugin: render the base
167 // style, preserve the pointer (see GetActiveThemePack).
168 if isPluginThemeID(st.ActiveThemeID) {
169 return view, nil
170 }
171 st.ActiveThemeID = ""
172 _ = saveThemeDesktopState(st)
173 return view, nil
174 }
175 if activeID == "" {
176 return view, nil
177 }
178 pack, err := a.loadThemeViewLocked(activeID, true)
179 if err != nil {
180 if isPluginThemeID(activeID) {
181 return view, nil
182 }
183 st.ActiveThemeID = ""
184 _ = saveThemeDesktopState(st)
185 return view, nil
186 }
187 view.ActiveThemeID = activeID
188 view.ActivePack = &pack
189 if pack.BaseStyle != "" {
190 view.EffectiveStyle = pack.BaseStyle
191 }
192 return view, nil
193 }
194
195 func (a *App) loadThemeViewLocked(id string, active bool) (ThemePackView, error) {
196 if isBuiltinThemeID(id) {
197 m := findBuiltinManifest(id)
198 if m == nil {
199 return ThemePackView{}, fmt.Errorf("unknown built-in theme %q", id)
200 }
201 return manifestToView(m, themeKindBase, active, "", ""), nil
202 }
203 if ot := findOfficialTheme(id); ot != nil {
204 m := ot.manifest
205 bgURL := officialAssetURL(m.ID, m.Background.Image)
206 pvURL := officialAssetURL(m.ID, officialPreviewName)
207 return manifestToView(&m, themeKindOfficial, active, bgURL, pvURL), nil
208 }
209 if pluginName, themeID, ok := parsePluginThemeID(id); ok {
210 pt := findPluginTheme(pluginName, themeID)
211 if pt == nil {
212 return ThemePackView{}, fmt.Errorf("plugin theme %q is unavailable (plugin %q must be installed and enabled)", id, pluginName)
213 }
214 return pluginThemeView(*pt, active), nil
215 }
216 m, err := loadUserThemeManifest(id)
217 if err != nil {
218 return ThemePackView{}, err
219 }
220 bgURL := ""
221 if m.Background != nil && m.Background.Image != "" {
222 bgURL = themeBackgroundURL(id, m.Background.Image)
223 }
224 taskURL := ""
225 if m.TaskBackground != nil && m.TaskBackground.Image != "" {
226 taskURL = themeBackgroundURL(id, m.TaskBackground.Image)
227 }
228 return manifestToView(m, themeKindUser, active, bgURL, "", taskURL), nil
229 }
230
231 // ActivateThemePack enables an official, user or plugin theme. Empty id clears
232 // the pack (same as DisableThemePack). Base style ids are rejected — use
233 // ActivateBaseStyle. Plugin theme ids (plugin:<plugin>:<theme>) persist in
234 // full so a later plugin reinstall can restore the theme.
235 func (a *App) ActivateThemePack(id string) error {
236 themeMu.Lock()
237 defer themeMu.Unlock()
238
239 id = strings.TrimSpace(id)
240 st := a.migrateThemeDesktopStateLocked()
241 if id == "" {
242 st.ActiveThemeID = ""
243 return saveThemeDesktopState(st)
244 }
245 if isBuiltinThemeID(id) {
246 return fmt.Errorf("base style %q is not a theme pack; use ActivateBaseStyle", id)
247 }
248 if isPluginThemeID(id) {
249 pluginName, themeID, ok := parsePluginThemeID(id)
250 if !ok {
251 return fmt.Errorf("invalid plugin theme id %q (want plugin:<plugin>:<theme>)", id)
252 }
253 pt := findPluginTheme(pluginName, themeID)
254 if pt == nil {
255 return fmt.Errorf("plugin theme %q is unavailable (plugin %q must be installed and enabled)", id, pluginName)
256 }
257 st.ActiveThemeID = pt.id
258 return saveThemeDesktopState(st)
259 }
260 if findOfficialTheme(id) != nil {
261 st.ActiveThemeID = id
262 return saveThemeDesktopState(st)
263 }
264 if _, err := loadUserThemeManifest(id); err != nil {
265 return fmt.Errorf("theme %q is missing or invalid", id)
266 }
267 st.ActiveThemeID = id
268 return saveThemeDesktopState(st)
269 }
270
271 // ActivateBaseStyle writes the base color direction and clears any active pack.
272 // Theme mode (auto/light/dark), fonts and zoom are preserved.
273 func (a *App) ActivateBaseStyle(style string) error {
274 themeMu.Lock()
275 defer themeMu.Unlock()
276
277 style = strings.TrimSpace(strings.ToLower(style))
278 if !isBuiltinThemeID(style) {
279 return fmt.Errorf("unknown base style %q", style)
280 }
281 themeMode, _ := a.desktopAppearanceLocked()
282 if err := a.SetDesktopAppearance(themeMode, style); err != nil {
283 return err
284 }
285 st := a.migrateThemeDesktopStateLocked()
286 st.ActiveThemeID = ""
287 return saveThemeDesktopState(st)
288 }
289
290 // DisableThemePack clears the active pack and restores the configured base style.
291 // Theme mode, fonts and zoom are preserved.
292 func (a *App) DisableThemePack() error {
293 themeMu.Lock()
294 defer themeMu.Unlock()
295 st := a.migrateThemeDesktopStateLocked()
296 st.ActiveThemeID = ""
297 return saveThemeDesktopState(st)
298 }
299
300 // RestoreGraphiteAppearance disables any pack and sets base style to Graphite.
301 // Theme mode, fonts and zoom are preserved.
302 func (a *App) RestoreGraphiteAppearance() error {
303 themeMu.Lock()
304 defer themeMu.Unlock()
305 themeMode, _ := a.desktopAppearanceLocked()
306 if err := a.SetDesktopAppearance(themeMode, "graphite"); err != nil {
307 return err
308 }
309 st := a.migrateThemeDesktopStateLocked()
310 st.ActiveThemeID = ""
311 return saveThemeDesktopState(st)
312 }
313
314 // ResetThemePack is a compatibility wrapper for older frontends.
315 // Prefer DisableThemePack or RestoreGraphiteAppearance.
316 func (a *App) ResetThemePack() error {
317 return a.DisableThemePack()
318 }
319
320 // migrateThemeDesktopStateLocked upgrades v1 state and clears invalid ids —
321 // except plugin: pointers, which are preserved even when unresolvable.
322 // Caller must hold themeMu. Side effect: may write desktop.theme_style when a
323 // v1 base-style activeThemeId is migrated.
324 func (a *App) migrateThemeDesktopStateLocked() ThemeDesktopState {
325 st := loadThemeDesktopState()
326 changed := false
327 id := strings.TrimSpace(st.ActiveThemeID)
328
329 // v1 stored base styles as activeThemeId — move them to desktop.theme_style.
330 if isBuiltinThemeID(id) {
331 themeMode, _ := a.desktopAppearanceLocked()
332 _ = a.SetDesktopAppearance(themeMode, id)
333 st.ActiveThemeID = ""
334 changed = true
335 } else if id != "" && !isPluginThemeID(id) && resolveActiveThemeID(st) == "" {
336 // Missing / corrupt official or user pack — clear pointer only. Plugin
337 // pointers are never auto-cleared: they survive a missing/disabled
338 // plugin so a reinstall restores the theme.
339 st.ActiveThemeID = ""
340 changed = true
341 }
342 if st.SchemaVersion != themeStateSchemaVer {
343 st.SchemaVersion = themeStateSchemaVer
344 changed = true
345 }
346 if changed {
347 _ = saveThemeDesktopState(st)
348 st = loadThemeDesktopState()
349 }
350 return st
351 }
352
353 func (a *App) desktopAppearanceLocked() (themeMode, baseStyle string) {
354 // Read-only snapshot of user desktop prefs. applyConfigOnly serializes
355 // writers; a concurrent save may race, which is acceptable for UI display.
356 cfg := config.LoadForEdit(config.UserConfigPath())
357 themeMode = cfg.DesktopTheme()
358 if themeMode == "" {
359 themeMode = "auto"
360 }
361 baseStyle = cfg.DesktopThemeStyle()
362 // Frontend maps legacy aliases; for API consumers normalize known bases.
363 if !isBuiltinThemeID(baseStyle) {
364 switch baseStyle {
365 case "ember":
366 baseStyle = "carbon"
367 case "midnight", "porcelain":
368 baseStyle = "nocturne"
369 case "sandstone", "linen":
370 baseStyle = "amber"
371 case "glacier":
372 baseStyle = "slate"
373 default:
374 baseStyle = "graphite"
375 }
376 }
377 return themeMode, baseStyle
378 }
379
380 func (a *App) desktopBaseStyleLocked() string {
381 _, style := a.desktopAppearanceLocked()
382 return style
383 }
384
385 // SaveThemePack creates or updates a user theme from the editor payload.
386 func (a *App) SaveThemePack(input ThemeSaveInput) (ThemePackView, error) {
387 themeMu.Lock()
388 defer themeMu.Unlock()
389
390 if isPluginThemeID(input.ID) {
391 return ThemePackView{}, errPluginThemeReadOnly(input.ID, "saved")
392 }
393 m := &ThemePackManifest{
394 SchemaVersion: themePackSchemaVersion,
395 ID: strings.TrimSpace(input.ID),
396 Name: input.Name,
397 Author: input.Author,
398 Description: input.Description,
399 License: input.License,
400 BaseStyle: input.BaseStyle,
401 Tokens: input.Tokens,
402 Recipes: input.Recipes,
403 Background: input.Background,
404 TaskBackground: input.TaskBackground,
405 }
406 // Preserve editor tuning while validation runs before the data URL has been
407 // decoded into its final file name. The placeholder is replaced below.
408 if !input.ClearBackground && strings.TrimSpace(input.BackgroundDataURL) != "" && m.Background != nil && m.Background.Image == "" {
409 m.Background.Image = "background.webp"
410 }
411 if !input.ClearTaskBackground && strings.TrimSpace(input.TaskBackgroundDataURL) != "" && m.TaskBackground != nil && m.TaskBackground.Image == "" {
412 m.TaskBackground.Image = "background-task.webp"
413 }
414 if err := validateThemePackManifest(m); err != nil {
415 return ThemePackView{}, err
416 }
417 if isReservedThemeID(m.ID) {
418 return ThemePackView{}, fmt.Errorf("built-in theme ids are reserved")
419 }
420
421 var imageBytes []byte
422 keepExistingImage := false
423
424 if input.ClearBackground {
425 m.Background = nil
426 } else if strings.TrimSpace(input.BackgroundDataURL) != "" {
427 name, data, err := decodeDataURLImage(input.BackgroundDataURL)
428 if err != nil {
429 return ThemePackView{}, err
430 }
431 imageBytes = data
432 if m.Background == nil {
433 bg := defaultThemePackBackground()
434 m.Background = &bg
435 }
436 m.Background.Image = name
437 // Re-validate after image assignment.
438 bg, err := normalizeThemeBackground(m.Background)
439 if err != nil {
440 return ThemePackView{}, err
441 }
442 m.Background = bg
443 } else if m.Background != nil && m.Background.Image != "" {
444 // Keep existing image from library when editing.
445 if userThemeExists(m.ID) {
446 keepExistingImage = true
447 } else {
448 return ThemePackView{}, fmt.Errorf("background image data is required for new themes with a background")
449 }
450 }
451
452 var taskImageBytes []byte
453 keepExistingTaskImage := false
454 if input.ClearTaskBackground {
455 m.TaskBackground = nil
456 } else if strings.TrimSpace(input.TaskBackgroundDataURL) != "" {
457 name, data, err := decodeDataURLImage(input.TaskBackgroundDataURL)
458 if err != nil {
459 return ThemePackView{}, err
460 }
461 taskImageBytes = data
462 if m.TaskBackground == nil {
463 bg := defaultThemePackTaskBackground()
464 m.TaskBackground = &bg
465 }
466 m.TaskBackground.Image = taskBackgroundImageName(name)
467 bg, err := normalizeThemeSceneBackground(m.TaskBackground)
468 if err != nil {
469 return ThemePackView{}, err
470 }
471 m.TaskBackground = bg
472 } else if m.TaskBackground != nil && m.TaskBackground.Image != "" {
473 if userThemeExists(m.ID) {
474 keepExistingTaskImage = true
475 } else {
476 return ThemePackView{}, fmt.Errorf("task background image data is required for new themes with a task background")
477 }
478 }
479
480 var staging string
481 var err error
482 var homeSource themeStagingImage
483 if keepExistingImage {
484 existing, err := resolveThemeImageAbs(m.ID, m.Background.Image)
485 if err != nil {
486 return ThemePackView{}, err
487 }
488 homeSource.path = existing
489 } else {
490 homeSource.bytes = imageBytes
491 }
492 var taskSource themeStagingImage
493 if keepExistingTaskImage {
494 existing, err := resolveThemeImageAbs(m.ID, m.TaskBackground.Image)
495 if err != nil {
496 return ThemePackView{}, err
497 }
498 taskSource.path = existing
499 } else {
500 taskSource.bytes = taskImageBytes
501 }
502 staging, err = writeThemeStaging(m, homeSource.path, homeSource.bytes, taskSource)
503 if err != nil {
504 return ThemePackView{}, err
505 }
506 defer os.RemoveAll(staging)
507
508 // Honor Replace: create/import-style saves must not silently overwrite.
509 // The editor passes Replace=true when editing an existing theme.
510 exists := userThemeExists(m.ID)
511 if exists && !input.Replace {
512 return ThemePackView{}, fmt.Errorf("theme %q already exists; set replace to overwrite", m.ID)
513 }
514 if err := publishThemeDir(m.ID, staging, exists && input.Replace); err != nil {
515 return ThemePackView{}, err
516 }
517
518 if input.Activate {
519 st := loadThemeDesktopState()
520 st.ActiveThemeID = m.ID
521 if err := saveThemeDesktopState(st); err != nil {
522 return ThemePackView{}, err
523 }
524 }
525 return a.loadThemeViewLocked(m.ID, input.Activate)
526 }
527
528 // DeleteThemePack removes a user theme. Active theme falls back to none (Graphite path).
529 func (a *App) DeleteThemePack(id string) error {
530 themeMu.Lock()
531 defer themeMu.Unlock()
532
533 id = strings.TrimSpace(id)
534 if isPluginThemeID(id) {
535 return errPluginThemeReadOnly(id, "deleted — disable or uninstall the plugin instead")
536 }
537 if isReservedThemeID(id) {
538 return fmt.Errorf("built-in themes cannot be deleted")
539 }
540 if err := deleteUserTheme(id); err != nil {
541 return err
542 }
543 st := loadThemeDesktopState()
544 if st.ActiveThemeID == id {
545 st.ActiveThemeID = ""
546 return saveThemeDesktopState(st)
547 }
548 return nil
549 }
550
551 // CopyThemePack duplicates a base, official or user theme into a new user theme id.
552 func (a *App) CopyThemePack(sourceID, newID, newName string) (ThemePackView, error) {
553 themeMu.Lock()
554 defer themeMu.Unlock()
555
556 sourceID = strings.TrimSpace(sourceID)
557 newID = strings.TrimSpace(newID)
558 if isPluginThemeID(sourceID) {
559 return ThemePackView{}, errPluginThemeReadOnly(sourceID, "duplicated")
560 }
561 if !themePackIDRe.MatchString(newID) || isReservedThemeID(newID) {
562 return ThemePackView{}, fmt.Errorf("invalid new theme id")
563 }
564 if userThemeExists(newID) {
565 return ThemePackView{}, fmt.Errorf("theme %q already exists", newID)
566 }
567
568 var m *ThemePackManifest
569 var imageBytes []byte
570 var taskImageBytes []byte
571 if isBuiltinThemeID(sourceID) {
572 src := findBuiltinManifest(sourceID)
573 if src == nil {
574 return ThemePackView{}, fmt.Errorf("unknown source theme")
575 }
576 cp := *src
577 m = &cp
578 } else if ot := findOfficialTheme(sourceID); ot != nil {
579 // Copying an official theme embeds a private copy of its background so the
580 // duplicate becomes an ordinary editable user theme.
581 cp := ot.manifest
582 m = &cp
583 data, _, err := readOfficialAsset(sourceID, cp.Background.Image)
584 if err != nil {
585 return ThemePackView{}, fmt.Errorf("read official background: %w", err)
586 }
587 imageBytes = data
588 } else {
589 src, err := loadUserThemeManifest(sourceID)
590 if err != nil {
591 return ThemePackView{}, err
592 }
593 m = src
594 if m.Background != nil && m.Background.Image != "" {
595 p, err := resolveThemeImageAbs(sourceID, m.Background.Image)
596 if err != nil {
597 return ThemePackView{}, err
598 }
599 imageBytes, err = os.ReadFile(p)
600 if err != nil {
601 return ThemePackView{}, err
602 }
603 }
604 if m.TaskBackground != nil && m.TaskBackground.Image != "" {
605 p, err := resolveThemeImageAbs(sourceID, m.TaskBackground.Image)
606 if err != nil {
607 return ThemePackView{}, err
608 }
609 taskImageBytes, err = os.ReadFile(p)
610 if err != nil {
611 return ThemePackView{}, err
612 }
613 }
614 }
615 m.ID = newID
616 if strings.TrimSpace(newName) != "" {
617 m.Name = strings.TrimSpace(newName)
618 } else {
619 m.Name = m.Name + " Copy"
620 }
621 if err := validateThemePackManifest(m); err != nil {
622 return ThemePackView{}, err
623 }
624 staging, err := writeThemeStaging(m, "", imageBytes, themeStagingImage{bytes: taskImageBytes})
625 if err != nil {
626 return ThemePackView{}, err
627 }
628 defer os.RemoveAll(staging)
629 if err := publishThemeDir(newID, staging, false); err != nil {
630 return ThemePackView{}, err
631 }
632 return a.loadThemeViewLocked(newID, false)
633 }
634
635 // ImportThemePack opens a file dialog (or uses sourcePath in tests) and imports a ZIP.
636 // When replace is false and the id exists, the extract is kept as a pending import
637 // (NeedsReplace=true) so a subsequent ImportThemePack("", true) publishes without
638 // re-opening the file dialog. Host paths never leave the Go side.
639 func (a *App) ImportThemePack(sourcePath string, replace bool) (ThemeImportResult, error) {
640 themeMu.Lock()
641 defer themeMu.Unlock()
642
643 // Confirm a previously staged conflict without re-picking a file.
644 path := strings.TrimSpace(sourcePath)
645 if path == "" && replace {
646 if pending := takePendingThemeImport(); pending != nil {
647 defer os.RemoveAll(pending.staging)
648 if err := publishThemeDir(pending.id, pending.staging, true); err != nil {
649 return ThemeImportResult{}, err
650 }
651 pack, err := a.loadThemeViewLocked(pending.id, false)
652 if err != nil {
653 return ThemeImportResult{}, err
654 }
655 return ThemeImportResult{Pack: pack, Replaced: true}, nil
656 }
657 // Fall through to dialog/path if nothing was pending (e.g. tests pass path).
658 }
659
660 if path == "" {
661 if a.ctx == nil {
662 return ThemeImportResult{}, fmt.Errorf("no theme package selected")
663 }
664 picked, err := a.nativeHost().OpenFileDialog(a.ctx, nativeDialogOptions{
665 Title: "Import Reasonix Theme",
666 Filters: []nativeFileFilter{
667 {DisplayName: "Reasonix Theme (*.reasonix-theme)", Pattern: "*.reasonix-theme"},
668 {DisplayName: "ZIP (*.zip)", Pattern: "*.zip"},
669 },
670 })
671 if err != nil {
672 return ThemeImportResult{}, err
673 }
674 path = picked
675 }
676 if path == "" {
677 return ThemeImportResult{}, nil
678 }
679
680 m, staging, err := importThemePackZIP(path)
681 if err != nil {
682 return ThemeImportResult{}, err
683 }
684
685 exists := userThemeExists(m.ID)
686 if exists && !replace {
687 // Stage for confirmation — do not delete staging; pending owns it.
688 pack := manifestToView(m, themeKindUser, false, "", "")
689 pendingID := setPendingThemeImport(m.ID, staging, pack)
690 return ThemeImportResult{
691 Pack: pack,
692 NeedsReplace: true,
693 PendingID: pendingID,
694 }, nil
695 }
696 defer os.RemoveAll(staging)
697 clearPendingThemeImport()
698
699 if err := publishThemeDir(m.ID, staging, replace || exists); err != nil {
700 return ThemeImportResult{}, err
701 }
702 pack, err := a.loadThemeViewLocked(m.ID, false)
703 if err != nil {
704 return ThemeImportResult{}, err
705 }
706 return ThemeImportResult{Pack: pack, Replaced: exists && replace}, nil
707 }
708
709 // ExportThemePack writes the theme to a user-selected destination.
710 func (a *App) ExportThemePack(id, destPath string) (string, error) {
711 themeMu.Lock()
712 defer themeMu.Unlock()
713
714 id = strings.TrimSpace(id)
715 if id == "" {
716 return "", fmt.Errorf("theme id is required")
717 }
718 if isPluginThemeID(id) {
719 return "", errPluginThemeReadOnly(id, "exported")
720 }
721 path := strings.TrimSpace(destPath)
722 if path == "" {
723 if a.ctx == nil {
724 return "", fmt.Errorf("no export path")
725 }
726 defaultName := id + themePackExt
727 picked, err := a.nativeHost().SaveFileDialog(a.ctx, nativeDialogOptions{
728 Title: "Export Reasonix Theme",
729 DefaultFilename: defaultName,
730 Filters: []nativeFileFilter{
731 {DisplayName: "Reasonix Theme (*.reasonix-theme)", Pattern: "*.reasonix-theme"},
732 },
733 })
734 if err != nil {
735 return "", err
736 }
737 path = picked
738 }
739 if path == "" {
740 return "", nil
741 }
742 if err := exportThemePackZIP(id, path); err != nil {
743 return "", err
744 }
745 if !strings.HasSuffix(strings.ToLower(path), themePackExt) {
746 path += themePackExt
747 }
748 return path, nil
749 }
750
751 // PickThemeBackground opens a native file dialog for a local background image.
752 // Returns a data URL for the editor preview — never exposes the absolute path.
753 func (a *App) PickThemeBackground() (string, error) {
754 if a.ctx == nil {
755 return "", fmt.Errorf("file dialog unavailable")
756 }
757 path, err := a.nativeHost().OpenFileDialog(a.ctx, nativeDialogOptions{
758 Title: "Choose Theme Background",
759 Filters: []nativeFileFilter{
760 {DisplayName: "Images (*.png;*.jpg;*.jpeg;*.webp)", Pattern: "*.png;*.jpg;*.jpeg;*.webp"},
761 },
762 })
763 if err != nil {
764 return "", err
765 }
766 if path == "" {
767 return "", nil
768 }
769 if err := validateThemeImageFile(path); err != nil {
770 return "", err
771 }
772 data, err := os.ReadFile(path)
773 if err != nil {
774 return "", err
775 }
776 if int64(len(data)) > themePackMaxImageBytes {
777 return "", fmt.Errorf("background image exceeds %d bytes", themePackMaxImageBytes)
778 }
779 mime := themeImageMIMEFromName(filepath.Base(path))
780 // Return as data URL so the frontend never needs the host path.
781 return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
782 }
783
783 lines GO