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