返回 DeepSeek-Reasonix
theme_official.go
根目录 / desktop / theme_official.go
1 package main
2
3 import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"
8 "io"
9 "io/fs"
10 "sort"
11 "sync"
12
13 "embed"
14 )
15
16 // Official themes are read-only, MIT-licensed Reasonix assets embedded into the
17 // desktop binary. They reuse the Theme Pack V1 validator and the same
18 // content-addressed asset route as user themes, but read from embed.FS instead
19 // of the user library and are served with immutable caching.
20 //
21 // Fail-closed contract: an invalid official entry is dropped from the registry
22 // (never listed, never served) without affecting startup; the build-time test
23 // in theme_official_test.go fails long before that can ship.
24
25 //go:embed themes/official
26 var officialThemesFS embed.FS
27
28 const (
29 officialThemeDirName = "themes/official"
30 officialPreviewName = "preview.webp"
31 officialBackgroundWidth = 2560
32 officialBackgroundHeight = 1440
33 officialPreviewWidth = 480
34 officialPreviewHeight = 270
35 officialMaxBackground = 2359296 // 2.25 MiB per background
36 officialMaxPreview = 122880 // 120 KiB per thumbnail
37 officialMaxTotalBytes = 18 << 20 // 18 MiB across all backgrounds
38 officialExpectedCount = 8 // release gate: all eight themes
39 themeKindBase = "base"
40 themeKindOfficial = "official"
41 themeKindUser = "user"
42 )
43
44 // Fixed gallery / list order (not alphabetical).
45 var officialThemeOrderFixed = []string{
46 "official-rose-dawn",
47 "official-fortune-forge",
48 "official-crimson-horizon",
49 "official-sage-breeze",
50 "official-spark-notebook",
51 "official-violet-starlight",
52 "official-cyan-stage",
53 "official-noir-gold",
54 }
55
56 type officialTheme struct {
57 manifest ThemePackManifest
58 bgDigest string
59 previewDigest string
60 bgSize int64
61 }
62
63 var (
64 officialOnce sync.Once
65 officialRegistry map[string]*officialTheme
66 officialOrder []string
67 officialLoadErr error
68 )
69
70 // loadOfficialRegistry parses and validates every embedded official theme once.
71 // Invalid entries are skipped (fail-closed); the first error is remembered for
72 // diagnostics and tests.
73 func loadOfficialRegistry() {
74 officialRegistry = map[string]*officialTheme{}
75 officialOrder = nil
76 entries, err := officialThemesFS.ReadDir(officialThemeDirName)
77 if err != nil {
78 officialLoadErr = fmt.Errorf("read official themes: %w", err)
79 return
80 }
81 var total int64
82 var firstErr error
83 remember := func(err error) {
84 if firstErr == nil {
85 firstErr = err
86 }
87 }
88 for _, e := range entries {
89 if !e.IsDir() {
90 continue
91 }
92 id := e.Name()
93 ot, err := loadOfficialTheme(id)
94 if err != nil {
95 remember(fmt.Errorf("official theme %q: %w", id, err))
96 continue
97 }
98 if _, dup := officialRegistry[ot.manifest.ID]; dup {
99 remember(fmt.Errorf("official theme id %q is duplicated", ot.manifest.ID))
100 continue
101 }
102 officialRegistry[ot.manifest.ID] = ot
103 officialOrder = append(officialOrder, ot.manifest.ID)
104 total += ot.bgSize
105 }
106 if total > officialMaxTotalBytes {
107 remember(fmt.Errorf("official backgrounds total %d bytes exceeds %d", total, officialMaxTotalBytes))
108 }
109 // Prefer the explicit product order; append any unexpected extras last.
110 ordered := make([]string, 0, len(officialRegistry))
111 seen := map[string]bool{}
112 for _, id := range officialThemeOrderFixed {
113 if _, ok := officialRegistry[id]; ok {
114 ordered = append(ordered, id)
115 seen[id] = true
116 }
117 }
118 var extras []string
119 for id := range officialRegistry {
120 if !seen[id] {
121 extras = append(extras, id)
122 }
123 }
124 sort.Strings(extras)
125 officialOrder = append(ordered, extras...)
126 officialLoadErr = firstErr
127 }
128
129 func loadOfficialTheme(dirID string) (*officialTheme, error) {
130 if !themePackIDRe.MatchString(dirID) {
131 return nil, fmt.Errorf("invalid directory name")
132 }
133 base := officialThemeDirName + "/" + dirID
134 raw, err := officialThemesFS.ReadFile(base + "/" + themePackManifestName)
135 if err != nil {
136 return nil, fmt.Errorf("read manifest: %w", err)
137 }
138 m, err := parseThemePackManifest(raw)
139 if err != nil {
140 return nil, err
141 }
142 if m.ID != dirID {
143 return nil, fmt.Errorf("manifest id %q does not match directory %q", m.ID, dirID)
144 }
145 if isBuiltinThemeID(m.ID) {
146 return nil, fmt.Errorf("official theme id %q collides with a base style", m.ID)
147 }
148 if m.Background == nil || m.Background.Image == "" {
149 return nil, fmt.Errorf("official themes require a background image")
150 }
151 if m.Background.Image != "background.webp" {
152 return nil, fmt.Errorf("official background must be background.webp, got %q", m.Background.Image)
153 }
154
155 bg, err := officialThemesFS.ReadFile(base + "/" + m.Background.Image)
156 if err != nil {
157 return nil, fmt.Errorf("read background: %w", err)
158 }
159 if err := validateOfficialImage(bg, m.Background.Image, officialBackgroundWidth, officialBackgroundHeight, officialMaxBackground); err != nil {
160 return nil, fmt.Errorf("background: %w", err)
161 }
162 preview, err := officialThemesFS.ReadFile(base + "/" + officialPreviewName)
163 if err != nil {
164 return nil, fmt.Errorf("read preview: %w", err)
165 }
166 if err := validateOfficialImage(preview, officialPreviewName, officialPreviewWidth, officialPreviewHeight, officialMaxPreview); err != nil {
167 return nil, fmt.Errorf("preview: %w", err)
168 }
169 return &officialTheme{
170 manifest: *m,
171 bgDigest: themeBytesDigest(bg),
172 previewDigest: themeBytesDigest(preview),
173 bgSize: int64(len(bg)),
174 }, nil
175 }
176
177 func validateOfficialImage(data []byte, name string, wantW, wantH int, maxBytes int64) error {
178 if int64(len(data)) > maxBytes {
179 return fmt.Errorf("%s exceeds %d bytes", name, maxBytes)
180 }
181 head := data
182 if len(head) > 512 {
183 head = head[:512]
184 }
185 mime := sniffThemeImageMIME(head, name)
186 if mime != "image/webp" {
187 return fmt.Errorf("%s must be WebP", name)
188 }
189 cfg, format, err := decodeThemeImageConfig(bytesReader(data), mime)
190 if err != nil {
191 return fmt.Errorf("decode %s: %w", name, err)
192 }
193 if format != "webp" {
194 return fmt.Errorf("%s must be WebP", name)
195 }
196 if cfg.Width != wantW || cfg.Height != wantH {
197 return fmt.Errorf("%s must be %d×%d, got %d×%d", name, wantW, wantH, cfg.Width, cfg.Height)
198 }
199 return nil
200 }
201
202 func bytesReader(b []byte) io.Reader {
203 return bytes.NewReader(b)
204 }
205
206 func themeBytesDigest(b []byte) string {
207 sum := sha256.Sum256(b)
208 return hex.EncodeToString(sum[:])[:16]
209 }
210
211 func officialThemes() []*officialTheme {
212 officialOnce.Do(loadOfficialRegistry)
213 out := make([]*officialTheme, 0, len(officialOrder))
214 for _, id := range officialOrder {
215 out = append(out, officialRegistry[id])
216 }
217 return out
218 }
219
220 func findOfficialTheme(id string) *officialTheme {
221 officialOnce.Do(loadOfficialRegistry)
222 return officialRegistry[id]
223 }
224
225 func isOfficialThemeID(id string) bool {
226 return findOfficialTheme(id) != nil
227 }
228
229 // isReservedThemeID covers both the six base styles and the eight official
230 // themes: user saves, imports, copies, overwrites and deletes must refuse them.
231 func isReservedThemeID(id string) bool {
232 return isBuiltinThemeID(id) || isOfficialThemeID(id)
233 }
234
235 // officialAssetURL builds the content-addressed URL for an embedded asset.
236 // Only the manifest-declared background and the fixed preview name are served.
237 func officialAssetURL(id, filename string) string {
238 ot := findOfficialTheme(id)
239 if ot == nil {
240 return ""
241 }
242 switch filename {
243 case ot.manifest.Background.Image:
244 return themeAssetURLPrefix + id + "/" + ot.bgDigest + "/" + filename
245 case officialPreviewName:
246 return themeAssetURLPrefix + id + "/" + ot.previewDigest + "/" + filename
247 default:
248 return ""
249 }
250 }
251
252 // readOfficialAsset returns embedded asset bytes after digest verification.
253 func readOfficialAsset(id, filename string) ([]byte, string, error) {
254 ot := findOfficialTheme(id)
255 if ot == nil {
256 return nil, "", fmt.Errorf("unknown official theme")
257 }
258 var name, digest string
259 switch filename {
260 case ot.manifest.Background.Image:
261 name, digest = ot.manifest.Background.Image, ot.bgDigest
262 case officialPreviewName:
263 name, digest = officialPreviewName, ot.previewDigest
264 default:
265 return nil, "", fmt.Errorf("asset not declared")
266 }
267 data, err := fs.ReadFile(officialThemesFS, officialThemeDirName+"/"+id+"/"+name)
268 if err != nil {
269 return nil, "", err
270 }
271 if got := themeBytesDigest(data); got != digest {
272 return nil, "", fmt.Errorf("digest mismatch")
273 }
274 return data, name, nil
275 }
276
277 // validateOfficialThemes gates the build: every embedded entry must parse and
278 // pass the V1 validator plus image budgets, and the release set must be complete.
279 func validateOfficialThemes() error {
280 officialOnce.Do(loadOfficialRegistry)
281 if officialLoadErr != nil {
282 return officialLoadErr
283 }
284 if len(officialOrder) != officialExpectedCount {
285 return fmt.Errorf("expected %d official themes, found %d", officialExpectedCount, len(officialOrder))
286 }
287 return nil
288 }
289
290 // resetOfficialRegistryForTest clears the cached registry (tests only).
291 func resetOfficialRegistryForTest() {
292 officialOnce = sync.Once{}
293 officialRegistry = nil
294 officialOrder = nil
295 officialLoadErr = nil
296 }
297
297 lines GO