| 1 | package cdp |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/base64" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "image/png" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strconv" |
| 13 | "strings" |
| 14 | "time" |
| 15 | |
| 16 | "reasonix/internal/browser" |
| 17 | ) |
| 18 | |
| 19 | // downloadPollInterval is how often a waiting browser_download re-reads the |
| 20 | // records the browser's progress events keep up to date. |
| 21 | const downloadPollInterval = 200 * time.Millisecond |
| 22 | |
| 23 | // downloadRecord is one download this executor observed, keyed by the guid |
| 24 | // Chrome assigns it. |
| 25 | type downloadRecord struct { |
| 26 | guid string |
| 27 | tab string |
| 28 | url string |
| 29 | suggested string |
| 30 | path string |
| 31 | state string |
| 32 | received int64 |
| 33 | total int64 |
| 34 | } |
| 35 | |
| 36 | func (d *downloadRecord) download() browser.Download { |
| 37 | bytesSeen := d.received |
| 38 | if d.state == "completed" && d.total > 0 { |
| 39 | bytesSeen = d.total |
| 40 | } |
| 41 | return browser.Download{ID: d.guid, URL: d.url, Path: d.path, State: d.state, Bytes: bytesSeen} |
| 42 | } |
| 43 | |
| 44 | // Screenshot writes a PNG this task owns and names it; image bytes reach the |
| 45 | // model through the tool's image channel, never through a control frame. |
| 46 | func (e *Executor) Screenshot(ctx context.Context, req browser.ScreenshotRequest) (browser.Screenshot, error) { |
| 47 | p, err := e.lookup(ctx, req.TabID) |
| 48 | if err != nil { |
| 49 | return browser.Screenshot{}, err |
| 50 | } |
| 51 | params, err := e.captureParams(ctx, p, req) |
| 52 | if err != nil { |
| 53 | return browser.Screenshot{}, err |
| 54 | } |
| 55 | var out struct { |
| 56 | Data string `json:"data"` |
| 57 | } |
| 58 | if err := e.conn.call(ctx, p.session, "Page.captureScreenshot", params, &out); err != nil { |
| 59 | return browser.Screenshot{}, fmt.Errorf("capture screenshot: %w", err) |
| 60 | } |
| 61 | data, err := base64.StdEncoding.DecodeString(out.Data) |
| 62 | if err != nil { |
| 63 | return browser.Screenshot{}, fmt.Errorf("decode screenshot: %w", err) |
| 64 | } |
| 65 | // The tab's registered ID, not the caller's argument: a screenshot names a |
| 66 | // file, and only IDs this executor minted may reach a path. |
| 67 | name := fmt.Sprintf("%s-%d.png", p.id, time.Now().UnixNano()) |
| 68 | path, err := e.artifactPath("screenshots", name) |
| 69 | if err != nil { |
| 70 | return browser.Screenshot{}, err |
| 71 | } |
| 72 | if err := os.WriteFile(path, data, 0o600); err != nil { |
| 73 | return browser.Screenshot{}, fmt.Errorf("write screenshot: %w", err) |
| 74 | } |
| 75 | shot := browser.Screenshot{Path: path, MIME: "image/png"} |
| 76 | if cfg, err := png.DecodeConfig(bytes.NewReader(data)); err == nil { |
| 77 | shot.Width, shot.Height = cfg.Width, cfg.Height |
| 78 | } |
| 79 | return shot, nil |
| 80 | } |
| 81 | |
| 82 | // captureParams turns the request into a capture region. Element and full-page |
| 83 | // clips are page coordinates, so the visual viewport's offset is added back. |
| 84 | func (e *Executor) captureParams(ctx context.Context, p *page, req browser.ScreenshotRequest) (map[string]any, error) { |
| 85 | params := map[string]any{"format": "png"} |
| 86 | if req.Ref == "" && !req.FullPage { |
| 87 | return params, nil |
| 88 | } |
| 89 | metrics, err := e.layout(ctx, p) |
| 90 | if err != nil { |
| 91 | return nil, err |
| 92 | } |
| 93 | params["captureBeyondViewport"] = true |
| 94 | if req.Ref != "" { |
| 95 | rect, err := e.locate(ctx, p, req.Ref) |
| 96 | if err != nil { |
| 97 | return nil, err |
| 98 | } |
| 99 | if rect.Hidden { |
| 100 | return nil, fmt.Errorf("element %s has no visible box to capture", req.Ref) |
| 101 | } |
| 102 | params["clip"] = map[string]any{ |
| 103 | "x": rect.X - rect.Width/2 + metrics.pageX, |
| 104 | "y": rect.Y - rect.Height/2 + metrics.pageY, |
| 105 | "width": rect.Width, "height": rect.Height, "scale": 1, |
| 106 | } |
| 107 | return params, nil |
| 108 | } |
| 109 | params["clip"] = map[string]any{"x": 0, "y": 0, "width": metrics.contentWidth, "height": metrics.contentHeight, "scale": 1} |
| 110 | return params, nil |
| 111 | } |
| 112 | |
| 113 | // layoutMetrics is the subset of Page.getLayoutMetrics this package uses. |
| 114 | type layoutMetrics struct { |
| 115 | pageX, pageY float64 |
| 116 | viewWidth, viewHeight float64 |
| 117 | contentWidth, contentHeight float64 |
| 118 | } |
| 119 | |
| 120 | func (e *Executor) layout(ctx context.Context, p *page) (layoutMetrics, error) { |
| 121 | var out struct { |
| 122 | CSSContentSize struct { |
| 123 | Width float64 `json:"width"` |
| 124 | Height float64 `json:"height"` |
| 125 | } `json:"cssContentSize"` |
| 126 | CSSVisualViewport struct { |
| 127 | PageX float64 `json:"pageX"` |
| 128 | PageY float64 `json:"pageY"` |
| 129 | ClientWidth float64 `json:"clientWidth"` |
| 130 | ClientHeight float64 `json:"clientHeight"` |
| 131 | } `json:"cssVisualViewport"` |
| 132 | } |
| 133 | if err := e.conn.call(ctx, p.session, "Page.getLayoutMetrics", nil, &out); err != nil { |
| 134 | return layoutMetrics{}, fmt.Errorf("read layout metrics: %w", err) |
| 135 | } |
| 136 | return layoutMetrics{ |
| 137 | pageX: out.CSSVisualViewport.PageX, pageY: out.CSSVisualViewport.PageY, |
| 138 | viewWidth: out.CSSVisualViewport.ClientWidth, viewHeight: out.CSSVisualViewport.ClientHeight, |
| 139 | contentWidth: out.CSSContentSize.Width, contentHeight: out.CSSContentSize.Height, |
| 140 | }, nil |
| 141 | } |
| 142 | |
| 143 | // setDownloadBehavior points a browser context's downloads at the task's |
| 144 | // artifact directory. allowAndName writes each file under its guid, which is |
| 145 | // the only name that cannot collide before the download is finished. |
| 146 | func (e *Executor) setDownloadBehavior(ctx context.Context, contextID string) error { |
| 147 | dir, err := e.artifactPath("downloads", "") |
| 148 | if err != nil { |
| 149 | return err |
| 150 | } |
| 151 | params := map[string]any{"behavior": "allowAndName", "downloadPath": dir, "eventsEnabled": true} |
| 152 | if contextID != "" { |
| 153 | params["browserContextId"] = contextID |
| 154 | } |
| 155 | if err := e.conn.call(ctx, "", "Browser.setDownloadBehavior", params, nil); err != nil { |
| 156 | return fmt.Errorf("configure downloads: %w", err) |
| 157 | } |
| 158 | return nil |
| 159 | } |
| 160 | |
| 161 | func (e *Executor) downloadWillBegin(params json.RawMessage) { |
| 162 | var ev struct { |
| 163 | FrameID string `json:"frameId"` |
| 164 | GUID string `json:"guid"` |
| 165 | URL string `json:"url"` |
| 166 | SuggestedFilename string `json:"suggestedFilename"` |
| 167 | } |
| 168 | if err := json.Unmarshal(params, &ev); err != nil || ev.GUID == "" { |
| 169 | return |
| 170 | } |
| 171 | e.mu.Lock() |
| 172 | defer e.mu.Unlock() |
| 173 | tab := "" |
| 174 | for _, p := range e.pages { |
| 175 | p.mu.Lock() |
| 176 | if p.doc.frame == ev.FrameID { |
| 177 | tab = p.id |
| 178 | p.downloads = append(p.downloads, ev.GUID) |
| 179 | } |
| 180 | p.mu.Unlock() |
| 181 | } |
| 182 | e.downloads[ev.GUID] = &downloadRecord{ |
| 183 | guid: ev.GUID, tab: tab, url: ev.URL, suggested: ev.SuggestedFilename, |
| 184 | state: "inProgress", path: filepath.Join(e.artifacts, "downloads", ev.GUID), |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func (e *Executor) downloadProgress(params json.RawMessage) { |
| 189 | var ev struct { |
| 190 | GUID string `json:"guid"` |
| 191 | TotalBytes float64 `json:"totalBytes"` |
| 192 | ReceivedBytes float64 `json:"receivedBytes"` |
| 193 | State string `json:"state"` |
| 194 | } |
| 195 | if err := json.Unmarshal(params, &ev); err != nil || ev.GUID == "" { |
| 196 | return |
| 197 | } |
| 198 | e.mu.Lock() |
| 199 | record, ok := e.downloads[ev.GUID] |
| 200 | if !ok { |
| 201 | e.mu.Unlock() |
| 202 | return |
| 203 | } |
| 204 | record.state = ev.State |
| 205 | record.received = int64(ev.ReceivedBytes) |
| 206 | record.total = int64(ev.TotalBytes) |
| 207 | finished := ev.State == "completed" |
| 208 | from, suggested := record.path, record.suggested |
| 209 | e.mu.Unlock() |
| 210 | if !finished { |
| 211 | return |
| 212 | } |
| 213 | if to, err := renameDownload(from, suggested); err == nil { |
| 214 | e.mu.Lock() |
| 215 | record.path = to |
| 216 | e.mu.Unlock() |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // renameDownload gives a finished download its suggested name without ever |
| 221 | // overwriting a file that is already there. |
| 222 | func renameDownload(from, suggested string) (string, error) { |
| 223 | name := safeFilename(suggested) |
| 224 | if name == "" { |
| 225 | return from, nil |
| 226 | } |
| 227 | dir := filepath.Dir(from) |
| 228 | target := filepath.Join(dir, name) |
| 229 | ext := filepath.Ext(name) |
| 230 | stem := strings.TrimSuffix(name, ext) |
| 231 | for i := 1; ; i++ { |
| 232 | if _, err := os.Stat(target); os.IsNotExist(err) { |
| 233 | break |
| 234 | } |
| 235 | if i > 500 { |
| 236 | return from, nil |
| 237 | } |
| 238 | target = filepath.Join(dir, stem+" ("+strconv.Itoa(i)+")"+ext) |
| 239 | } |
| 240 | if err := os.Rename(from, target); err != nil { |
| 241 | return from, err |
| 242 | } |
| 243 | return target, nil |
| 244 | } |
| 245 | |
| 246 | // safeFilename keeps a server-suggested name from escaping the download |
| 247 | // directory or naming a device. |
| 248 | func safeFilename(name string) string { |
| 249 | name = strings.TrimSpace(filepath.Base(strings.ReplaceAll(name, `\`, "/"))) |
| 250 | if name == "." || name == ".." || name == "/" { |
| 251 | return "" |
| 252 | } |
| 253 | cleaned := strings.Map(func(r rune) rune { |
| 254 | if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) { |
| 255 | return '_' |
| 256 | } |
| 257 | return r |
| 258 | }, name) |
| 259 | if len(cleaned) > 180 { |
| 260 | cleaned = cleaned[:180] |
| 261 | } |
| 262 | return cleaned |
| 263 | } |
| 264 | |
| 265 | // Downloads lists a tab's downloads, optionally waiting for the ones still |
| 266 | // running to settle. |
| 267 | func (e *Executor) Downloads(ctx context.Context, req browser.DownloadsRequest) ([]browser.Download, error) { |
| 268 | p, err := e.lookup(ctx, req.TabID) |
| 269 | if err != nil { |
| 270 | return nil, err |
| 271 | } |
| 272 | deadline := time.Now().Add(req.WaitFor) |
| 273 | for { |
| 274 | list, pending := e.tabDownloads(p) |
| 275 | if !pending || req.WaitFor <= 0 || time.Now().After(deadline) { |
| 276 | return list, nil |
| 277 | } |
| 278 | select { |
| 279 | case <-ctx.Done(): |
| 280 | return list, ctx.Err() |
| 281 | case <-time.After(downloadPollInterval): |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | func (e *Executor) tabDownloads(p *page) ([]browser.Download, bool) { |
| 287 | p.mu.Lock() |
| 288 | guids := append([]string(nil), p.downloads...) |
| 289 | p.mu.Unlock() |
| 290 | e.mu.Lock() |
| 291 | defer e.mu.Unlock() |
| 292 | list := make([]browser.Download, 0, len(guids)) |
| 293 | pending := false |
| 294 | for _, guid := range guids { |
| 295 | record, ok := e.downloads[guid] |
| 296 | if !ok { |
| 297 | continue |
| 298 | } |
| 299 | if record.state == "inProgress" { |
| 300 | pending = true |
| 301 | } |
| 302 | list = append(list, record.download()) |
| 303 | } |
| 304 | return list, pending |
| 305 | } |
| 306 |