返回 DeepSeek-Reasonix
scroll_diagnostics_export.go
根目录 / desktop / scroll_diagnostics_export.go
1 package main
2
3 import (
4 "archive/zip"
5 "bytes"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "math"
13 "path/filepath"
14 "regexp"
15 "slices"
16 "strings"
17 "time"
18 )
19
20 const (
21 scrollDiagnosticSchemaVersion = 2
22 maxScrollDiagnosticEvents = 4096
23 maxScrollDiagnosticPayloadBytes = 2 << 20
24 )
25
26 var scrollDiagnosticReportIDPattern = regexp.MustCompile(`^[a-f0-9]{32,64}$`)
27 var scrollDiagnosticBuildValuePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
28 var allowedScrollDiagnosticEventTypes = map[string]bool{
29 "start": true, "stop": true, "mark": true, "sample": true, "wheel": true,
30 "scroll": true, "scroll-write": true, "items-rendered": true, "list-height": true,
31 "row-measure": true, "scroll-state": true, "blank-check": true, "blank-reset": true, "recovery": true,
32 }
33
34 type scrollDiagnosticPayload struct {
35 SchemaVersion int `json:"schemaVersion"`
36 Manifest scrollDiagnosticManifest `json:"manifest"`
37 Summary scrollDiagnosticSummary `json:"summary"`
38 Events []scrollDiagnosticEvent `json:"events"`
39 }
40
41 type scrollDiagnosticManifest struct {
42 ReportID string `json:"reportId"`
43 CreatedAt string `json:"createdAt"`
44 BuildCommit string `json:"buildCommit"`
45 BuildChannel string `json:"buildChannel"`
46 Platform string `json:"platform"`
47 UserAgent string `json:"userAgent"`
48 DevicePixelRatio float64 `json:"devicePixelRatio"`
49 ViewportWidth int `json:"viewportWidth"`
50 ViewportHeight int `json:"viewportHeight"`
51 ReducedMotion bool `json:"reducedMotion"`
52 TranscriptWidth float64 `json:"transcriptWidth"`
53 ContentWidth float64 `json:"contentWidth"`
54 FontSize float64 `json:"fontSize"`
55 LineHeight float64 `json:"lineHeight"`
56 ProcessFold string `json:"processFoldPreference"`
57 ReasoningDisplay string `json:"reasoningDisplayMode"`
58 }
59
60 type scrollDiagnosticSummary struct {
61 DurationMS int `json:"durationMs"`
62 EventCount int `json:"eventCount"`
63 DroppedEventCount int `json:"droppedEventCount"`
64 MarkerCount int `json:"markerCount"`
65 }
66
67 type scrollDiagnosticEvent struct {
68 T int `json:"t"`
69 Type string `json:"type"`
70 ScrollTop *float64 `json:"scrollTop,omitempty"`
71 ScrollHeight *float64 `json:"scrollHeight,omitempty"`
72 ClientHeight *float64 `json:"clientHeight,omitempty"`
73 BottomDistance *float64 `json:"bottomDistance,omitempty"`
74 MountedRows *float64 `json:"mountedRows,omitempty"`
75 TotalRows *float64 `json:"totalRows,omitempty"`
76 FirstVisibleIndex *float64 `json:"firstVisibleIndex,omitempty"`
77 FirstVisibleTop *float64 `json:"firstVisibleTop,omitempty"`
78 DeltaY *float64 `json:"deltaY,omitempty"`
79 TargetTop *float64 `json:"targetTop,omitempty"`
80 TargetIndex json.RawMessage `json:"targetIndex,omitempty"`
81 ListHeight *float64 `json:"listHeight,omitempty"`
82 RowIndex *float64 `json:"rowIndex,omitempty"`
83 EstimatedSize *float64 `json:"estimatedSize,omitempty"`
84 PreviousSize *float64 `json:"previousSize,omitempty"`
85 MeasuredSize *float64 `json:"measuredSize,omitempty"`
86 SizeDelta *float64 `json:"sizeDelta,omitempty"`
87 ContentRevision *float64 `json:"contentRevision,omitempty"`
88 DisclosureCount *float64 `json:"disclosureCount,omitempty"`
89 SettleFrame *float64 `json:"settleFrame,omitempty"`
90 OffBottomFrames *float64 `json:"offBottomFrames,omitempty"`
91 StagnantFrames *float64 `json:"stagnantFrames,omitempty"`
92 Mode string `json:"mode,omitempty"`
93 PreviousMode string `json:"previousMode,omitempty"`
94 Owner string `json:"owner,omitempty"`
95 WriteKind string `json:"writeKind,omitempty"`
96 Source string `json:"source,omitempty"`
97 Phase string `json:"phase,omitempty"`
98 RowKind string `json:"rowKind,omitempty"`
99 FoldState string `json:"foldState,omitempty"`
100 State string `json:"state,omitempty"`
101 Reason string `json:"reason,omitempty"`
102 AtBottom *bool `json:"atBottom,omitempty"`
103 Scrollable *bool `json:"scrollable,omitempty"`
104 Blank *bool `json:"blank,omitempty"`
105 ReaderIntent *bool `json:"readerIntent,omitempty"`
106 CanClaimTail *bool `json:"canClaimTail,omitempty"`
107 Substantial *bool `json:"substantial,omitempty"`
108 TailCommand *bool `json:"tailCommand,omitempty"`
109 }
110
111 func decodeScrollDiagnosticsPayload(payload string) (scrollDiagnosticPayload, error) {
112 if len(payload) == 0 || len(payload) > maxScrollDiagnosticPayloadBytes {
113 return scrollDiagnosticPayload{}, errors.New("invalid scroll diagnostic payload size")
114 }
115 var decoded scrollDiagnosticPayload
116 decoder := json.NewDecoder(strings.NewReader(payload))
117 decoder.DisallowUnknownFields()
118 if err := decoder.Decode(&decoded); err != nil {
119 return scrollDiagnosticPayload{}, errors.New("invalid scroll diagnostic payload")
120 }
121 if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
122 return scrollDiagnosticPayload{}, errors.New("invalid scroll diagnostic payload")
123 }
124 if err := validateScrollDiagnosticsPayload(decoded); err != nil {
125 return scrollDiagnosticPayload{}, err
126 }
127 return decoded, nil
128 }
129
130 func validateScrollDiagnosticsPayload(payload scrollDiagnosticPayload) error {
131 if payload.SchemaVersion != scrollDiagnosticSchemaVersion {
132 return errors.New("unsupported scroll diagnostic schema")
133 }
134 if err := validateScrollDiagnosticManifest(payload.Manifest); err != nil {
135 return err
136 }
137 if err := validateScrollDiagnosticSummary(payload.Summary, len(payload.Events)); err != nil {
138 return err
139 }
140 markerCount := 0
141 for _, event := range payload.Events {
142 if err := validateScrollDiagnosticEvent(event, payload.Summary.DurationMS); err != nil {
143 return err
144 }
145 if event.Type == "mark" {
146 markerCount++
147 }
148 }
149 if markerCount != payload.Summary.MarkerCount {
150 return errors.New("invalid scroll diagnostic marker count")
151 }
152 return nil
153 }
154
155 func validateScrollDiagnosticManifest(manifest scrollDiagnosticManifest) error {
156 if !scrollDiagnosticReportIDPattern.MatchString(manifest.ReportID) {
157 return errors.New("invalid scroll diagnostic report id")
158 }
159 if _, err := time.Parse(time.RFC3339Nano, manifest.CreatedAt); err != nil {
160 return errors.New("invalid scroll diagnostic creation time")
161 }
162 if !scrollDiagnosticBuildValuePattern.MatchString(manifest.BuildCommit) {
163 return errors.New("invalid scroll diagnostic build commit")
164 }
165 if manifest.BuildChannel != "test" && manifest.BuildChannel != "development" && manifest.BuildChannel != "dev" {
166 return errors.New("invalid scroll diagnostic build channel")
167 }
168 if manifest.Platform != "windows" && manifest.Platform != "macos" && manifest.Platform != "linux" && manifest.Platform != "other" {
169 return errors.New("invalid scroll diagnostic platform")
170 }
171 if len(manifest.UserAgent) == 0 || len(manifest.UserAgent) > 512 || hasUnsafeDiagnosticText(manifest.UserAgent) {
172 return errors.New("invalid scroll diagnostic user agent")
173 }
174 return validateScrollDiagnosticDisplay(manifest)
175 }
176
177 func validateScrollDiagnosticDisplay(manifest scrollDiagnosticManifest) error {
178 if !finiteInRange(manifest.DevicePixelRatio, 0.25, 16) || manifest.ViewportWidth < 0 || manifest.ViewportWidth > 32768 || manifest.ViewportHeight < 0 || manifest.ViewportHeight > 32768 ||
179 !finiteInRange(manifest.TranscriptWidth, 0, 32768) || !finiteInRange(manifest.ContentWidth, 0, 32768) ||
180 !finiteInRange(manifest.FontSize, 0, 512) || !finiteInRange(manifest.LineHeight, 0, 1024) {
181 return errors.New("invalid scroll diagnostic display metrics")
182 }
183 if !oneOf(manifest.ProcessFold, "auto", "expanded") || !oneOf(manifest.ReasoningDisplay, "hidden", "summary", "auto", "expanded", "legacy-collapsed", "pending") {
184 return errors.New("invalid scroll diagnostic display preferences")
185 }
186 return nil
187 }
188
189 func validateScrollDiagnosticSummary(summary scrollDiagnosticSummary, eventCount int) error {
190 if eventCount == 0 || eventCount > maxScrollDiagnosticEvents {
191 return errors.New("invalid scroll diagnostic event count")
192 }
193 if summary.EventCount != eventCount || summary.DurationMS < 0 || summary.DurationMS > 95_000 || summary.DroppedEventCount < 0 || summary.DroppedEventCount > 1_000_000 || summary.MarkerCount < 0 || summary.MarkerCount > eventCount {
194 return errors.New("invalid scroll diagnostic summary")
195 }
196 return nil
197 }
198
199 func hasUnsafeDiagnosticText(value string) bool {
200 for _, r := range value {
201 if r < 0x20 || r == 0x7f {
202 return true
203 }
204 }
205 return false
206 }
207
208 func finiteInRange(value, min, max float64) bool {
209 return !math.IsNaN(value) && !math.IsInf(value, 0) && value >= min && value <= max
210 }
211
212 func validateScrollDiagnosticEvent(event scrollDiagnosticEvent, durationMS int) error {
213 if !allowedScrollDiagnosticEventTypes[event.Type] || event.T < 0 || event.T > durationMS+1_000 {
214 return errors.New("invalid scroll diagnostic event")
215 }
216 for _, value := range []*float64{
217 event.ScrollTop, event.ScrollHeight, event.ClientHeight, event.BottomDistance,
218 event.MountedRows, event.TotalRows, event.FirstVisibleIndex, event.FirstVisibleTop,
219 event.DeltaY, event.TargetTop, event.ListHeight, event.RowIndex, event.EstimatedSize,
220 event.PreviousSize, event.MeasuredSize, event.SizeDelta, event.ContentRevision,
221 event.DisclosureCount, event.SettleFrame, event.OffBottomFrames, event.StagnantFrames,
222 } {
223 if value != nil && !finiteInRange(*value, -1_000_000_000, 1_000_000_000) {
224 return errors.New("invalid scroll diagnostic event metric")
225 }
226 }
227 for _, value := range []*float64{
228 event.RowIndex, event.ContentRevision, event.DisclosureCount,
229 event.SettleFrame, event.OffBottomFrames, event.StagnantFrames,
230 } {
231 if value != nil && (*value < 0 || *value != math.Trunc(*value)) {
232 return errors.New("invalid scroll diagnostic event counter")
233 }
234 }
235 if err := validateScrollDiagnosticTargetIndex(event.TargetIndex); err != nil {
236 return err
237 }
238 return validateScrollDiagnosticEventLabels(event)
239 }
240
241 func validateScrollDiagnosticTargetIndex(value json.RawMessage) error {
242 if len(value) == 0 {
243 return nil
244 }
245 var number float64
246 if err := json.Unmarshal(value, &number); err == nil {
247 if finiteInRange(number, 0, 1_000_000_000) && number == math.Trunc(number) {
248 return nil
249 }
250 return errors.New("invalid scroll diagnostic target index")
251 }
252 var label string
253 if json.Unmarshal(value, &label) != nil || label != "LAST" {
254 return errors.New("invalid scroll diagnostic target index")
255 }
256 return nil
257 }
258
259 func validateScrollDiagnosticEventLabels(event scrollDiagnosticEvent) error {
260 if event.Mode != "" && !oneOf(event.Mode, "tail-follow", "manual", "user-resize", "selection", "restoring", "unknown") {
261 return errors.New("invalid scroll diagnostic mode")
262 }
263 if event.PreviousMode != "" && !oneOf(event.PreviousMode, "tail-follow", "manual", "user-resize", "selection", "restoring", "unknown") {
264 return errors.New("invalid scroll diagnostic previous mode")
265 }
266 if event.Owner != "" && !oneOf(event.Owner, "tail-follow", "jump", "rewind", "jump-bottom", "custom-scrollbar", "selection-edge-scroll", "recovery", "other") {
267 return errors.New("invalid scroll diagnostic owner")
268 }
269 if event.WriteKind != "" && !oneOf(event.WriteKind, "scrollTo", "scrollBy", "scrollToIndex") {
270 return errors.New("invalid scroll diagnostic write kind")
271 }
272 if event.Source != "" && !oneOf(event.Source,
273 "reset", "user-scroll-intent", "manual-reading", "reader-intent-ended", "scroll-delivered",
274 "tail-content-changed", "content-shrank", "layout-height-changed", "viewport-resized",
275 "user-resize-begin", "user-resize-end", "selection-begin", "selection-end",
276 "programmatic-begin", "programmatic-end", "jump-bottom", "jump-index", "scroll-offset",
277 "recovery-begin", "recovery-end", "native-scrollbar-release", "other") {
278 return errors.New("invalid scroll diagnostic source")
279 }
280 if event.Phase != "" && !oneOf(event.Phase, "initial", "settle") {
281 return errors.New("invalid scroll diagnostic phase")
282 }
283 if event.RowKind != "" && !oneOf(event.RowKind,
284 "older-history", "user", "process-header", "reasoning", "tool", "tool-batch", "tool-group", "phase",
285 "process-notice", "notice", "compaction", "answer", "extension", "turn-actions") {
286 return errors.New("invalid scroll diagnostic row kind")
287 }
288 if event.FoldState != "" && !oneOf(event.FoldState, "none", "open", "closed", "mixed") {
289 return errors.New("invalid scroll diagnostic fold state")
290 }
291 if event.State != "" && !oneOf(event.State, "begin", "suspend", "retry", "done", "cancelled", "expired") {
292 return errors.New("invalid scroll diagnostic state")
293 }
294 if event.Reason != "" && !oneOf(event.Reason, "user-takeover", "surface-switch", "superseded", "viewport-blank", "other") {
295 return errors.New("invalid scroll diagnostic reason")
296 }
297 return nil
298 }
299
300 func oneOf(value string, candidates ...string) bool {
301 return slices.Contains(candidates, value)
302 }
303
304 func buildScrollDiagnosticsArchive(payload string) ([]byte, string, error) {
305 decoded, err := decodeScrollDiagnosticsPayload(payload)
306 if err != nil {
307 return nil, "", err
308 }
309 manifest, err := json.MarshalIndent(decoded.Manifest, "", " ")
310 if err != nil {
311 return nil, "", errors.New("encode scroll diagnostic manifest")
312 }
313 summary, err := json.MarshalIndent(decoded.Summary, "", " ")
314 if err != nil {
315 return nil, "", errors.New("encode scroll diagnostic summary")
316 }
317 var eventLines bytes.Buffer
318 encoder := json.NewEncoder(&eventLines)
319 encoder.SetEscapeHTML(false)
320 for _, event := range decoded.Events {
321 if err := encoder.Encode(event); err != nil {
322 return nil, "", errors.New("encode scroll diagnostic events")
323 }
324 }
325 digest := sha256.Sum256(eventLines.Bytes())
326 checksum := fmt.Appendf(nil, "%s scroll-events.jsonl\n", hex.EncodeToString(digest[:]))
327
328 var archive bytes.Buffer
329 zw := zip.NewWriter(&archive)
330 entries := []struct {
331 name string
332 data []byte
333 }{
334 {name: "manifest.json", data: append(manifest, '\n')},
335 {name: "summary.json", data: append(summary, '\n')},
336 {name: "scroll-events.jsonl", data: eventLines.Bytes()},
337 {name: "sha256.txt", data: checksum},
338 }
339 for _, entry := range entries {
340 writer, createErr := zw.Create(entry.name)
341 if createErr != nil {
342 _ = zw.Close()
343 return nil, "", errors.New("create scroll diagnostic archive")
344 }
345 if _, writeErr := writer.Write(entry.data); writeErr != nil {
346 _ = zw.Close()
347 return nil, "", errors.New("write scroll diagnostic archive")
348 }
349 }
350 if err := zw.Close(); err != nil {
351 return nil, "", errors.New("finish scroll diagnostic archive")
352 }
353 return archive.Bytes(), decoded.Manifest.ReportID, nil
354 }
355
356 // ExportScrollDiagnostics validates a local, content-free trace, asks the user
357 // where to save it, and writes a ZIP. It performs no network requests.
358 func (a *App) ExportScrollDiagnostics(payload string) (string, error) {
359 archive, reportID, err := buildScrollDiagnosticsArchive(payload)
360 if err != nil {
361 return "", err
362 }
363 if a.ctx == nil {
364 return "", nil
365 }
366 defaultFilename := fmt.Sprintf("reasonix-scroll-diagnostics-%s.zip", reportID[:8])
367 path, err := a.nativeHost().SaveFileDialog(a.ctx, nativeDialogOptions{
368 Title: "Export scroll diagnostics",
369 DefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),
370 DefaultFilename: safeExportFilename(defaultFilename),
371 CanCreateDirectories: true,
372 Filters: exportFileFilters("application/zip", ".zip"),
373 })
374 if err != nil || path == "" {
375 return "", err
376 }
377 if filepath.Ext(path) == "" {
378 path += ".zip"
379 }
380 if err := a.SaveExportFile(path, string(archive), false); err != nil {
381 return "", err
382 }
383 return path, nil
384 }
385
385 lines GO