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