-
Notifications
You must be signed in to change notification settings - Fork 24
Expand file tree
/
Copy pathsnapshot.go
More file actions
193 lines (181 loc) · 6.56 KB
/
Copy pathsnapshot.go
File metadata and controls
193 lines (181 loc) · 6.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
package menuet
import (
"encoding/json"
"fmt"
"os"
"time"
)
// SnapshotSchema is the version string embedded in every snapshot file.
// Bump this when the snapshot JSON shape changes so consumers can detect
// incompatible versions. Existing fields should never change meaning.
const SnapshotSchema = "menuet-snapshot/v1"
// snapshotMaxDepth caps recursion into submenus when building a snapshot.
// A malformed Children callback that always returns more children would
// otherwise hang the snapshot. Eight levels is far past anything sensible
// in a menu.
const snapshotMaxDepth = 8
// Snapshot is a JSON-serializable picture of an App's current menu —
// MenuState plus the resolved top-level items, with submenus recursively
// expanded. It's produced by `MENUET_SNAPSHOT_PATH=… ./app` and consumed
// by the HTML renderer to draw a faithful mockup outside AppKit (for
// websites, README screenshots, regression tests).
//
// The snapshot is pure data — there is no code path back to the app from
// a snapshot file, so rendering one is safe even if the file came from a
// third party. The renderer's job is to honor the data and reject any
// shape outside the schema.
type Snapshot struct {
Schema string `json:"schema"`
State *MenuState `json:"state,omitempty"`
Items []SnapshotItem `json:"items"`
}
// SnapshotItem is the snapshot-only mirror of MenuItem. It is intentionally
// separate from internalItem (which carries cgo bookkeeping fields like
// Unique/ParentUnique and a live *MenuItem pointer) so the snapshot stays
// a self-contained data payload with no live references.
type SnapshotItem struct {
// Type is "regular", "separator", "search", or "image". Empty means
// regular for backward-compatibility when the field is omitted.
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Runs []TextRun `json:"runs,omitempty"`
Subtitle []TextRun `json:"subtitle,omitempty"`
Image string `json:"image,omitempty"`
FontSize int `json:"fontSize,omitempty"`
FontWeight FontWeight `json:"fontWeight,omitempty"`
Color Color `json:"color,omitempty"`
Monospaced bool `json:"monospaced,omitempty"`
Shortcut *Shortcut `json:"shortcut,omitempty"`
State bool `json:"state,omitempty"`
// Image ("image" Type) fields. The picture's bytes are deliberately NOT
// captured: a screenshot base64s to ~1MB, and a snapshot is a structural
// record of the menu, not an asset bundle. Renderers draw a placeholder at
// the item's declared bound. ImageSource is "data" or "path".
ImageWidth int `json:"imageWidth,omitempty"`
ImageHeight int `json:"imageHeight,omitempty"`
ImageSource string `json:"imageSource,omitempty"`
// Children are the expanded submenu, populated by the snapshotter.
Children []SnapshotItem `json:"children,omitempty"`
}
// maybeWriteSnapshot is the entry point used by RunApplication. If
// MENUET_SNAPSHOT_PATH is unset, it returns false and normal startup
// continues. If set, it waits MENUET_SNAPSHOT_DELAY (default 2s) so the
// app's startup goroutines have a chance to populate state, then writes
// the snapshot to the path and returns true. The caller is expected to
// short-circuit further startup (no AppKit, no Carbon, no goroutines)
// when it returns true.
//
// The delay is the one tunable: an app that fetches data on startup will
// often want a longer delay before its menu reflects real values. Set
// MENUET_SNAPSHOT_DELAY=5s (or any time.Duration string) to override.
func (a *Application) maybeWriteSnapshot() bool {
path := os.Getenv("MENUET_SNAPSHOT_PATH")
if path == "" {
return false
}
delay := 2 * time.Second
if s := os.Getenv("MENUET_SNAPSHOT_DELAY"); s != "" {
d, err := time.ParseDuration(s)
if err != nil {
fmt.Fprintf(os.Stderr, "menuet: invalid MENUET_SNAPSHOT_DELAY %q: %v\n", s, err)
os.Exit(2)
}
delay = d
}
time.Sleep(delay)
if err := a.writeSnapshot(path); err != nil {
fmt.Fprintf(os.Stderr, "menuet: snapshot write failed: %v\n", err)
os.Exit(1)
}
return true
}
// writeSnapshot captures the current MenuState and top-level Children,
// recursively expands submenus, and writes the result as indented JSON.
func (a *Application) writeSnapshot(path string) error {
snap := Snapshot{
Schema: SnapshotSchema,
State: a.currentState,
}
if a.Children != nil {
snap.Items = snapshotItems(a.Children(), 0)
}
b, err := json.MarshalIndent(snap, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
b = append(b, '\n')
if err := os.WriteFile(path, b, 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// snapshotItems converts a []MenuItem to []SnapshotItem, recursing into
// each item's Children callback up to snapshotMaxDepth. It deliberately
// does NOT share code with buildInternalItem because that path has cgo
// side effects (registerGlobalHotkey) that must not fire during a
// snapshot.
func snapshotItems(items []MenuItem, depth int) []SnapshotItem {
if depth >= snapshotMaxDepth {
return nil
}
out := make([]SnapshotItem, len(items))
for i, item := range items {
out[i] = snapshotItem(item, depth)
}
return out
}
func snapshotItem(item MenuItem, depth int) SnapshotItem {
switch v := item.(type) {
case Regular:
s := SnapshotItem{
Type: "regular",
Text: v.Text,
Runs: v.Runs,
Subtitle: v.Subtitle,
Image: v.Image,
FontSize: v.FontSize,
FontWeight: v.FontWeight,
Color: v.Color,
Monospaced: v.Monospaced,
Shortcut: v.Shortcut,
State: v.State,
}
if v.Children != nil {
s.Children = snapshotItems(v.Children(), depth+1)
}
return s
case Separator:
return SnapshotItem{Type: "separator"}
case Image:
// Record the effective bound (applying the same defaults the ObjC side
// would) so a renderer knows how much room the picture takes.
w, h := v.MaxWidth, v.MaxHeight
if w <= 0 {
w = DefaultMaxImageWidth
}
if h <= 0 {
h = DefaultMaxImageHeight
}
source := "data"
if v.Path != "" {
source = "path"
}
return SnapshotItem{
Type: "image",
ImageWidth: w,
ImageHeight: h,
ImageSource: source,
}
case Search:
s := SnapshotItem{Type: "search", Text: v.Placeholder}
// Snapshot search results with an empty query — the same call the
// real menu makes when the search field first appears. Authors who
// want a richer demo can synthesize results conditionally on the
// MENUET_SNAPSHOT_PATH env var inside their Results callback.
if v.Results != nil {
s.Children = snapshotItems(v.Results(""), depth+1)
}
return s
}
return SnapshotItem{}
}