| 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 |