| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "image" |
| 10 | _ "image/jpeg" |
| 11 | _ "image/png" |
| 12 | "io" |
| 13 | "net/http" |
| 14 | "net/url" |
| 15 | "os" |
| 16 | "path/filepath" |
| 17 | "strconv" |
| 18 | "strings" |
| 19 | "sync" |
| 20 | |
| 21 | "reasonix/internal/control" |
| 22 | "reasonix/internal/servecontract" |
| 23 | "reasonix/internal/session" |
| 24 | "reasonix/internal/sessionexport" |
| 25 | ) |
| 26 | |
| 27 | type SessionExportHandle struct { |
| 28 | ExportID string `json:"exportId"` |
| 29 | Snapshot session.ExportSnapshot `json:"snapshot"` |
| 30 | Format string `json:"format"` |
| 31 | } |
| 32 | type SessionExportChunk struct { |
| 33 | Data string `json:"data"` |
| 34 | NextOffset int64 `json:"nextOffset"` |
| 35 | Done bool `json:"done"` |
| 36 | } |
| 37 | type SessionExportPage struct { |
| 38 | Index int `json:"index"` |
| 39 | Offset int64 `json:"offset"` |
| 40 | Data string `json:"data"` |
| 41 | Done bool `json:"done"` |
| 42 | Width int `json:"width"` |
| 43 | Height int `json:"height"` |
| 44 | } |
| 45 | type SessionExportResult struct { |
| 46 | Paths []string `json:"paths"` |
| 47 | Records int `json:"records"` |
| 48 | Pages int `json:"pages"` |
| 49 | } |
| 50 | type sessionExportJob struct { |
| 51 | mu sync.Mutex |
| 52 | handle SessionExportHandle |
| 53 | ctx context.Context |
| 54 | cancel context.CancelFunc |
| 55 | dir, path string |
| 56 | workspaceRoot string |
| 57 | sourceHostID string |
| 58 | query *session.Query |
| 59 | controller *control.Controller |
| 60 | client *http.Client |
| 61 | base, route string |
| 62 | observation json.RawMessage |
| 63 | prepared bool |
| 64 | records, pages int |
| 65 | pageOffset int64 |
| 66 | } |
| 67 | |
| 68 | func (a *App) exportJob(id string) (*sessionExportJob, error) { |
| 69 | a.sessionExportMu.Lock() |
| 70 | defer a.sessionExportMu.Unlock() |
| 71 | job := a.sessionExports[id] |
| 72 | if job == nil { |
| 73 | return nil, errors.New("session export is no longer available") |
| 74 | } |
| 75 | return job, nil |
| 76 | } |
| 77 | |
| 78 | // BeginSessionExportForTarget captures the source before the native dialog. A |
| 79 | // tab is only a compatibility resolver; no later operation consults that tab. |
| 80 | func (a *App) BeginSessionExportForTarget(selector SessionSelector, tabID, format, title, observation string) (SessionExportHandle, error) { |
| 81 | switch format { |
| 82 | case "markdown", "json", "pdf", "image", "clipboard", "diagnostic": |
| 83 | default: |
| 84 | return SessionExportHandle{}, errors.New("unsupported export format") |
| 85 | } |
| 86 | ctx, cancel := context.WithCancel(a.bootContext()) |
| 87 | job := &sessionExportJob{ctx: ctx, cancel: cancel, observation: json.RawMessage(observation)} |
| 88 | success := false |
| 89 | defer func() { |
| 90 | if !success { |
| 91 | cancel() |
| 92 | if job.dir != "" { |
| 93 | _ = os.RemoveAll(job.dir) |
| 94 | } |
| 95 | } |
| 96 | }() |
| 97 | if len(observation) > 64<<10 || (observation != "" && !json.Valid(job.observation)) { |
| 98 | return SessionExportHandle{}, errors.New("invalid export observation") |
| 99 | } |
| 100 | if err := a.captureSessionExportSource(job, selector, tabID, format); err != nil { |
| 101 | return SessionExportHandle{}, err |
| 102 | } |
| 103 | if title != "" { |
| 104 | job.handle.Snapshot.Title = title |
| 105 | } |
| 106 | job.handle.ExportID = "export-" + newTabID() |
| 107 | job.handle.Format = format |
| 108 | var err error |
| 109 | if format != "clipboard" { |
| 110 | extension, mime := ".md", "text/markdown" |
| 111 | switch format { |
| 112 | case "json", "diagnostic": |
| 113 | extension, mime = ".json", "application/json" |
| 114 | case "pdf": |
| 115 | extension, mime = ".pdf", "application/pdf" |
| 116 | case "image": |
| 117 | extension, mime = ".png", "image/png" |
| 118 | } |
| 119 | base := job.handle.Snapshot.Title |
| 120 | if format == "diagnostic" { |
| 121 | base += "-session-diagnostics" |
| 122 | } |
| 123 | job.path, err = a.nativeHost().SaveFileDialog(ctx, nativeDialogOptions{Title: "Export session", DefaultFilename: safeExportFilename(base + extension), CanCreateDirectories: true, Filters: exportFileFilters(mime, extension)}) |
| 124 | if err != nil { |
| 125 | return SessionExportHandle{}, err |
| 126 | } |
| 127 | if job.path == "" { |
| 128 | return SessionExportHandle{}, nil |
| 129 | } |
| 130 | if filepath.Ext(job.path) == "" { |
| 131 | job.path += extension |
| 132 | } |
| 133 | } |
| 134 | job.dir, err = os.MkdirTemp("", "reasonix-desktop-export-") |
| 135 | if err != nil { |
| 136 | return SessionExportHandle{}, err |
| 137 | } |
| 138 | a.sessionExportMu.Lock() |
| 139 | if a.sessionExports == nil { |
| 140 | a.sessionExports = map[string]*sessionExportJob{} |
| 141 | } |
| 142 | a.sessionExports[job.handle.ExportID] = job |
| 143 | a.sessionExportMu.Unlock() |
| 144 | success = true |
| 145 | a.exportProgress(job, "preparing") |
| 146 | return job.handle, nil |
| 147 | } |
| 148 | |
| 149 | func (a *App) exportProgress(job *sessionExportJob, phase string) { |
| 150 | a.emitRuntimeEvent("session_export_progress", map[string]any{"exportId": job.handle.ExportID, "title": job.handle.Snapshot.Title, "phase": phase, "records": job.records, "pages": job.pages}) |
| 151 | } |
| 152 | func (a *App) prepareSessionExport(job *sessionExportJob) error { |
| 153 | if err := job.ctx.Err(); err != nil { |
| 154 | return err |
| 155 | } |
| 156 | if job.prepared { |
| 157 | return nil |
| 158 | } |
| 159 | a.exportProgress(job, "reading") |
| 160 | if job.client != nil { |
| 161 | format := job.handle.Format |
| 162 | if format == "clipboard" { |
| 163 | format = "markdown" |
| 164 | } |
| 165 | if format == "pdf" || format == "image" { |
| 166 | format = "blocks" |
| 167 | } |
| 168 | request, _ := json.Marshal(map[string]any{"snapshot": job.handle.Snapshot, "format": format}) |
| 169 | resp, err := serveDoForSession(job.ctx, job.client, http.MethodPost, sessionExportURL(job.base, "/session-export/document", job.handle.Snapshot.Ref.SessionID, false), request, job.route) |
| 170 | if err != nil { |
| 171 | return err |
| 172 | } |
| 173 | defer resp.Body.Close() |
| 174 | if resp.StatusCode != http.StatusOK { |
| 175 | return errors.New("remote export failed or source changed; upgrade the remote service if the session was switched or taken over") |
| 176 | } |
| 177 | job.records, err = strconv.Atoi(resp.Header.Get("X-Reasonix-Export-Records")) |
| 178 | if err != nil || job.records < 0 { |
| 179 | return errors.New("remote export record count is invalid") |
| 180 | } |
| 181 | file, err := os.OpenFile(filepath.Join(job.dir, format), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) |
| 182 | if err != nil { |
| 183 | return err |
| 184 | } |
| 185 | _, copyErr := io.Copy(file, resp.Body) |
| 186 | closeErr := file.Close() |
| 187 | if err = errors.Join(copyErr, closeErr); err != nil { |
| 188 | return err |
| 189 | } |
| 190 | } else { |
| 191 | doc, err := sessionexport.Build(job.ctx, job.query, job.handle.Snapshot, job.dir, func(count int) { |
| 192 | job.records = count |
| 193 | if count%100 == 0 { |
| 194 | a.exportProgress(job, "reading") |
| 195 | } |
| 196 | }) |
| 197 | if err != nil { |
| 198 | return err |
| 199 | } |
| 200 | job.records = doc.Records |
| 201 | } |
| 202 | job.prepared = true |
| 203 | a.exportProgress(job, "rendering") |
| 204 | return nil |
| 205 | } |
| 206 | |
| 207 | func (a *App) ReadSessionExportChunk(id string, offset int64) (SessionExportChunk, error) { |
| 208 | job, err := a.exportJob(id) |
| 209 | if err != nil { |
| 210 | return SessionExportChunk{}, err |
| 211 | } |
| 212 | job.mu.Lock() |
| 213 | defer job.mu.Unlock() |
| 214 | if offset < 0 { |
| 215 | return SessionExportChunk{}, errors.New("invalid export offset") |
| 216 | } |
| 217 | if err = a.prepareSessionExport(job); err != nil { |
| 218 | return SessionExportChunk{}, err |
| 219 | } |
| 220 | format := "blocks" |
| 221 | if job.handle.Format == "clipboard" { |
| 222 | format = "markdown" |
| 223 | } |
| 224 | file, err := os.Open(filepath.Join(job.dir, format)) |
| 225 | if err != nil { |
| 226 | return SessionExportChunk{}, err |
| 227 | } |
| 228 | defer file.Close() |
| 229 | info, err := file.Stat() |
| 230 | if err != nil { |
| 231 | return SessionExportChunk{}, err |
| 232 | } |
| 233 | if offset > info.Size() { |
| 234 | return SessionExportChunk{}, errors.New("invalid export offset") |
| 235 | } |
| 236 | bytes := make([]byte, min(int64(1<<20), info.Size()-offset)) |
| 237 | n, err := file.ReadAt(bytes, offset) |
| 238 | if err != nil && !errors.Is(err, io.EOF) { |
| 239 | return SessionExportChunk{}, err |
| 240 | } |
| 241 | return SessionExportChunk{Data: base64.StdEncoding.EncodeToString(bytes[:n]), NextOffset: offset + int64(n), Done: offset+int64(n) == info.Size()}, nil |
| 242 | } |
| 243 | |
| 244 | func (a *App) AppendSessionExportPage(id string, page SessionExportPage) error { |
| 245 | job, err := a.exportJob(id) |
| 246 | if err != nil { |
| 247 | return err |
| 248 | } |
| 249 | job.mu.Lock() |
| 250 | defer job.mu.Unlock() |
| 251 | if err = job.ctx.Err(); err != nil { |
| 252 | return err |
| 253 | } |
| 254 | if job.handle.Format != "pdf" && job.handle.Format != "image" { |
| 255 | return errors.New("export does not accept pages") |
| 256 | } |
| 257 | if page.Index != job.pages || page.Offset != job.pageOffset || len(page.Data) > 2<<20 { |
| 258 | return errors.New("export page order or size is invalid") |
| 259 | } |
| 260 | data, err := base64.StdEncoding.DecodeString(page.Data) |
| 261 | if len(data) > 1<<20 { |
| 262 | return errors.New("export page chunk exceeds one MiB") |
| 263 | } |
| 264 | if err != nil { |
| 265 | return err |
| 266 | } |
| 267 | file, err := os.OpenFile(filepath.Join(job.dir, fmt.Sprintf("page-%06d", page.Index)), os.O_CREATE|os.O_WRONLY, 0600) |
| 268 | if err != nil { |
| 269 | return err |
| 270 | } |
| 271 | n, writeErr := file.WriteAt(data, page.Offset) |
| 272 | closeErr := file.Close() |
| 273 | if err = errors.Join(writeErr, closeErr); err != nil { |
| 274 | return err |
| 275 | } |
| 276 | job.pageOffset += int64(n) |
| 277 | if page.Done { |
| 278 | if page.Width <= 0 || page.Height <= 0 || page.Width > 8192 || page.Height > 8192 { |
| 279 | return errors.New("invalid export page dimensions") |
| 280 | } |
| 281 | source, openErr := os.Open(filepath.Join(job.dir, fmt.Sprintf("page-%06d", page.Index))) |
| 282 | if openErr != nil { |
| 283 | return openErr |
| 284 | } |
| 285 | config, encoding, decodeErr := image.DecodeConfig(source) |
| 286 | expected := "png" |
| 287 | if job.handle.Format == "pdf" { |
| 288 | expected = "jpeg" |
| 289 | } |
| 290 | if decodeErr != nil || encoding != expected || config.Width != page.Width || config.Height != page.Height { |
| 291 | source.Close() |
| 292 | return errors.New("export page encoding or dimensions do not match") |
| 293 | } |
| 294 | if _, err := source.Seek(0, io.SeekStart); err != nil { |
| 295 | source.Close() |
| 296 | return err |
| 297 | } |
| 298 | _, _, decodeErr = image.Decode(source) |
| 299 | source.Close() |
| 300 | if decodeErr != nil { |
| 301 | return fmt.Errorf("incomplete export page: %w", decodeErr) |
| 302 | } |
| 303 | |
| 304 | meta, _ := json.Marshal(pageDimensions{Width: page.Width, Height: page.Height}) |
| 305 | if err = os.WriteFile(filepath.Join(job.dir, fmt.Sprintf("page-%06d.json", page.Index)), meta, 0600); err != nil { |
| 306 | return err |
| 307 | } |
| 308 | job.pages++ |
| 309 | job.pageOffset = 0 |
| 310 | a.exportProgress(job, "rendering") |
| 311 | } |
| 312 | return nil |
| 313 | } |
| 314 | |
| 315 | func (a *App) FinishSessionExport(id string) (SessionExportResult, error) { |
| 316 | result := SessionExportResult{Paths: []string{}} |
| 317 | job, err := a.exportJob(id) |
| 318 | if err != nil { |
| 319 | return result, err |
| 320 | } |
| 321 | job.mu.Lock() |
| 322 | defer job.mu.Unlock() |
| 323 | if err = job.ctx.Err(); err != nil { |
| 324 | return result, err |
| 325 | } |
| 326 | if err = a.validateSessionExportSource(job); err != nil { |
| 327 | return result, err |
| 328 | } |
| 329 | |
| 330 | if job.pageOffset != 0 { |
| 331 | return result, errors.New("export has an incomplete page") |
| 332 | } |
| 333 | if job.handle.Format == "diagnostic" { |
| 334 | err = a.writeSessionDiagnosticExport(job) |
| 335 | } else { |
| 336 | if err = a.prepareSessionExport(job); err != nil { |
| 337 | return result, err |
| 338 | } |
| 339 | if err = a.validateSessionExportSource(job); err != nil { |
| 340 | return result, err |
| 341 | } |
| 342 | a.exportProgress(job, "saving") |
| 343 | switch job.handle.Format { |
| 344 | case "markdown", "json": |
| 345 | err = writeStreamingExport(job.path, func(dst io.Writer) error { |
| 346 | src, err := os.Open(filepath.Join(job.dir, job.handle.Format)) |
| 347 | if err != nil { |
| 348 | return err |
| 349 | } |
| 350 | defer src.Close() |
| 351 | _, err = copyExportContext(job.ctx, dst, src) |
| 352 | return err |
| 353 | }) |
| 354 | case "pdf": |
| 355 | err = writeStreamingExport(job.path, func(dst io.Writer) error { |
| 356 | return writeExportPDF(job.ctx, dst, job.dir, job.pages, job.handle.Snapshot.Title) |
| 357 | }) |
| 358 | case "image": |
| 359 | result.Paths, err = publishExportImages(job.ctx, job.dir, job.path, job.pages) |
| 360 | case "clipboard": |
| 361 | } |
| 362 | } |
| 363 | if err != nil { |
| 364 | return result, err |
| 365 | } |
| 366 | if job.path != "" && len(result.Paths) == 0 { |
| 367 | result.Paths = append(result.Paths, job.path) |
| 368 | } |
| 369 | result.Records = job.records |
| 370 | result.Pages = job.pages |
| 371 | a.exportProgress(job, "complete") |
| 372 | a.sessionExportMu.Lock() |
| 373 | delete(a.sessionExports, id) |
| 374 | a.sessionExportMu.Unlock() |
| 375 | job.cancel() |
| 376 | _ = os.RemoveAll(job.dir) |
| 377 | return result, nil |
| 378 | } |
| 379 | |
| 380 | func (a *App) CancelSessionExport(id string) error { |
| 381 | a.sessionExportMu.Lock() |
| 382 | job := a.sessionExports[id] |
| 383 | delete(a.sessionExports, id) |
| 384 | a.sessionExportMu.Unlock() |
| 385 | if job == nil { |
| 386 | return nil |
| 387 | } |
| 388 | job.cancel() |
| 389 | job.mu.Lock() |
| 390 | defer job.mu.Unlock() |
| 391 | _ = os.RemoveAll(job.dir) |
| 392 | a.exportProgress(job, "cancelled") |
| 393 | return nil |
| 394 | } |
| 395 | func (a *App) cancelSessionExports() { |
| 396 | a.sessionExportMu.Lock() |
| 397 | jobs := a.sessionExports |
| 398 | a.sessionExports = nil |
| 399 | a.sessionExportMu.Unlock() |
| 400 | for _, job := range jobs { |
| 401 | job.cancel() |
| 402 | go func(j *sessionExportJob) { j.mu.Lock(); defer j.mu.Unlock(); _ = os.RemoveAll(j.dir) }(job) |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | type exportContextReader struct { |
| 407 | ctx context.Context |
| 408 | r io.Reader |
| 409 | } |
| 410 | |
| 411 | func (r exportContextReader) Read(p []byte) (int, error) { |
| 412 | if err := r.ctx.Err(); err != nil { |
| 413 | return 0, err |
| 414 | } |
| 415 | return r.r.Read(p) |
| 416 | } |
| 417 | func copyExportContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) { |
| 418 | return io.Copy(dst, exportContextReader{ctx, src}) |
| 419 | } |
| 420 | |
| 421 | func (a *App) validateSessionExportSource(job *sessionExportJob) error { |
| 422 | if job.client == nil { |
| 423 | return job.query.ValidateExportSource(job.handle.Snapshot) |
| 424 | } |
| 425 | body, _ := json.Marshal(job.handle.Snapshot) |
| 426 | response, err := serveDoForSession(job.ctx, job.client, http.MethodPost, sessionExportURL(job.base, "/session-export/validate", job.handle.Snapshot.Ref.SessionID, false), body, job.route) |
| 427 | if err != nil { |
| 428 | return err |
| 429 | } |
| 430 | response.Body.Close() |
| 431 | if response.StatusCode != http.StatusNoContent { |
| 432 | return errors.New("export source changed or is unavailable; upgrade the remote service if the session was switched or taken over") |
| 433 | } |
| 434 | return nil |
| 435 | } |
| 436 | |
| 437 | func (a *App) captureSessionExportSource(job *sessionExportJob, selector SessionSelector, tabID, format string) error { |
| 438 | ctx := job.ctx |
| 439 | if a.isRemoteTab(tabID) { |
| 440 | a.remoteTabMu.Lock() |
| 441 | tab := a.remoteTabs[tabID] |
| 442 | if tab == nil || !tab.capabilities[servecontract.SessionExportV1] { |
| 443 | a.remoteTabMu.Unlock() |
| 444 | return errors.New("remote service does not support session-export-v1; upgrade it to export the complete session") |
| 445 | } |
| 446 | if !remoteExportSelectorMatches(selector, tab) { |
| 447 | a.remoteTabMu.Unlock() |
| 448 | return errors.New("export target changed") |
| 449 | } |
| 450 | job.sourceHostID, job.workspaceRoot = tab.ref.HostID, tab.ref.Workspace |
| 451 | job.client, job.base, job.route = tab.client, tab.base, tab.routing.currentPath |
| 452 | a.remoteTabMu.Unlock() |
| 453 | if job.client == nil || job.route == "" { |
| 454 | return errors.New("remote session is unavailable") |
| 455 | } |
| 456 | sessionID, ok := strings.CutPrefix(job.route, remoteSessionIDRoutePrefix) |
| 457 | if !ok || sessionID == "" { |
| 458 | return errors.New("remote session has no canonical identity") |
| 459 | } |
| 460 | resp, err := serveDoForSession(ctx, job.client, http.MethodGet, sessionExportURL(job.base, "/session-export/snapshot", sessionID, format == "diagnostic"), nil, job.route) |
| 461 | if err != nil { |
| 462 | return err |
| 463 | } |
| 464 | defer resp.Body.Close() |
| 465 | if resp.StatusCode != http.StatusOK { |
| 466 | return errors.New("unable to capture remote export snapshot") |
| 467 | } |
| 468 | if err = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&job.handle.Snapshot); err != nil { |
| 469 | return err |
| 470 | } |
| 471 | if selector.Ref != nil && selector.Ref.SessionID != job.handle.Snapshot.Ref.SessionID { |
| 472 | return errors.New("export target changed") |
| 473 | } |
| 474 | } else { |
| 475 | if selector.Ref == nil && selector.Source == nil && selector.SessionPath == "" && selector.TopicID == "" { |
| 476 | a.mu.RLock() |
| 477 | tab := a.tabByIDLocked(tabID) |
| 478 | if tab != nil { |
| 479 | selector.SessionPath = tab.currentSessionPath() |
| 480 | if tab.SessionID != "" { |
| 481 | selector.Ref = &session.SessionRef{HostID: localDesktopHostID, SessionID: tab.SessionID} |
| 482 | } |
| 483 | } |
| 484 | a.mu.RUnlock() |
| 485 | } |
| 486 | target, err := a.resolveSessionTargetWithArchived(selector, true) |
| 487 | if err != nil { |
| 488 | return err |
| 489 | } |
| 490 | if target.SessionRef.SessionID == "" { |
| 491 | return newSessionOperationError("unsupported", "This historical format cannot guarantee a complete export.") |
| 492 | } |
| 493 | job.query = a.desktopSessionService("").Query() |
| 494 | job.controller = target.Controller |
| 495 | job.workspaceRoot = target.WorkspaceRoot |
| 496 | if format == "diagnostic" { |
| 497 | job.handle.Snapshot, err = job.query.CaptureDiagnosticSnapshot(ctx, target.SessionRef) |
| 498 | if err != nil { |
| 499 | return err |
| 500 | } |
| 501 | } else { |
| 502 | job.handle.Snapshot, err = job.query.CaptureExportSnapshot(ctx, target.SessionRef) |
| 503 | if err != nil { |
| 504 | return err |
| 505 | } |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | return nil |
| 510 | } |
| 511 | |
| 512 | func sessionExportURL(base, path, sessionID string, diagnostic bool) string { |
| 513 | endpoint, err := url.Parse(serveURL(base, path)) |
| 514 | if err != nil { |
| 515 | return serveURL(base, path) |
| 516 | } |
| 517 | query := endpoint.Query() |
| 518 | if sessionID != "" { |
| 519 | query.Set("sessionId", sessionID) |
| 520 | } |
| 521 | if diagnostic { |
| 522 | query.Set("diagnostic", "1") |
| 523 | } |
| 524 | endpoint.RawQuery = query.Encode() |
| 525 | return endpoint.String() |
| 526 | } |
| 527 | |
| 528 | // Called under remoteTabMu before any remote read or save dialog. |
| 529 | func remoteExportSelectorMatches(selector SessionSelector, tab *remoteTab) bool { |
| 530 | if selector.Ref != nil { |
| 531 | return selector.Ref.HostID == tab.ref.HostID && remoteSessionIDRoutePrefix+selector.Ref.SessionID == tab.routing.currentPath |
| 532 | } |
| 533 | if selector.Source != nil { |
| 534 | return selector.Source.HostID == tab.ref.HostID && (selector.Source.Path == tab.session.path || selector.Source.Path == tab.routing.currentPath) |
| 535 | } |
| 536 | return selector.SessionPath == "" || selector.SessionPath == tab.session.path || selector.SessionPath == tab.routing.currentPath |
| 537 | } |
| 538 |