From 5a10e8d165a3092a4fed6c5478ebde0e17550439 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 19:31:52 +0000 Subject: [PATCH 01/19] Plan other browsers, captures and designs A research note with a phased to-do list: open the page in the browsers installed on this computer (and serve the workspace's changes in Chromium browsers and Firefox), capture it, keep captures and imported designs per workspace, and check the page against a design pixel by pixel, over the live page or on stills. SPEC gets the stories and an M5 milestone, and the README's roadmap drops the two items that shipped. --- README.md | 2 +- docs/BROWSERS_RESEARCH.md | 310 ++++++++++++++++++++++++++++++++++++++ docs/SPEC.md | 16 +- 3 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 docs/BROWSERS_RESEARCH.md diff --git a/README.md b/README.md index 757d31e..07b0d38 100644 --- a/README.md +++ b/README.md @@ -228,9 +228,9 @@ The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application S What shipped, release by release, is in the **[CHANGELOG](CHANGELOG.md)**. Next: - [ ] Parameters and scenarios for actions -- [ ] Edit in your own editor (watch the overrides folder), and export/import patch sets for teammates - [ ] Search across every file the page loaded - [ ] Drive your own Chrome over CDP +- [ ] Open the page in your other browsers (with your changes in Chromium browsers and Firefox), capture it, and check it against designs pixel by pixel ([research](docs/BROWSERS_RESEARCH.md)) - [ ] Vue's renders and a data-flow view in the component inspector ([research](docs/INSPECTOR_RESEARCH.md)) - [ ] Signed and notarized builds (and with them, installing updates in place on macOS) diff --git a/docs/BROWSERS_RESEARCH.md b/docs/BROWSERS_RESEARCH.md new file mode 100644 index 0000000..3561237 --- /dev/null +++ b/docs/BROWSERS_RESEARCH.md @@ -0,0 +1,310 @@ +# Research: other browsers, captures and designs + +**Question.** Two things in one request: + +1. **Other browsers.** Show the browsers installed on this computer next to the address bar. Open the page in any of them to see whether something is broken there, and search the tabs they have open. +2. **Captures and designs.** Put a thumbnail at the right end of the toolbar that opens a list of images. The images are captures of the page, and designs to check it against pixel by pixel. + +**Short answer.** Yes, in steps. Four findings shape the design: + +1. **Opening a page in another browser works everywhere. Opening it with your overrides and rules depends on the browser's engine:** + - **Chromium browsers** (Chrome, Edge, Brave, Vivaldi, Opera, Arc, Chromium): the app's engine already runs over a WebSocket connection (`engine/websocketTransport`, which the integration tests use). A Chromium browser the app launches can serve the workspace's changes the way the app's own page does. This is roadmap M3's external Chrome. + - **Firefox** no longer speaks CDP. WebDriver BiDi can pause and answer requests, but it needs a second, smaller engine that reuses the app's matching, rules and transforms. + - **Safari** has no automation that can change what it loads. The app can only open the page in it. To check the WebKit engine with your changes, the app can use Playwright's WebKit build, on every system. +2. **Captures come from CDP.** The viewport, the full page or one element can be captured at any pixel density, without resizing the window. +3. **A design can go over the live page, even on sites whose CSP forbids images from other sources.** The app draws the design on a canvas in an isolated world (verified below). It can't be HTML in the app's own window, because the native page view is drawn above the app's DOM. +4. **Most of the plumbing exists.** The app already has: a CDP connection to an outside browser, a transport-agnostic engine, the element picker, per-workspace stores and a still of the page for popovers. + +--- + +## 1. What the reference app does + +The screenshots show two popovers. + +- **Browser switcher.** At the address bar's left is a 2×2 cluster of browser icons (Arc, Safari, Chrome, and a dashed slot to add one). It opens a list headed **Search browser tabs…**, with a gear and a chevron. Tabs are grouped by browser (Arc: a Figma file, a `localhost:5199` page, …), each with its favicon, title and host. +- **Files.** At the toolbar's right is a stack of thumbnails. It opens a list headed **Search files…**, with segments **All · Captures · Exports**. Each row has a thumbnail, a file name (`page-834.png`, `page-834-full.png`), a line such as "Export · Image · 15h ago", and a chevron. + +## 2. What the app has today + +| Piece | Where | What this work gets from it | +|---|---|---| +| A still of the page | `PageController/snapshotPage` (`capturePage()`: the viewport, as JPEG), `usePageSnapshot` | Popovers over the page freeze it into this still, since the native view is drawn above the DOM. The two new popovers do the same | +| CDP over WebSocket | `engine/websocketTransport` (`CdpConnection`, `attachToPage`). `test/helpers/chromium.ts` launches Chromium with `--remote-debugging-port=0` and a profile of its own | Driving an outside Chromium browser, and running the engine on its tabs | +| A transport-agnostic engine | `InterceptionEngine` / `PageInterception` over `CdpTransport`. Plain functions live in `engine/rules` (header, CORS and block rules) and `engine/transform` (headers, charsets, SRI, source maps). Matching is in `OverrideMatcher` and `requestMatches`; patch mode is `patchLive` | Overrides and rules in a Chromium browser as they are. A Firefox engine reuses the plain functions | +| Element picking | The inspector's picker (SPEC §6.13), in any frame | Capturing one element | +| Per-workspace stores | `ActionStore`, `OverrideStore`: written atomically, one write at a time, removed with their workspace | A `ShotStore` built the same way | +| Chromium flags the app needs | `chromiumFlags` (`LOCAL_NETWORK_ACCESS_FEATURES`) | The same flags for a Chromium browser the app launches | +| Toolbar in both places | `PreviewToolbar` (placement `editor` or `window`) | The two new buttons show in the editor and in the website's own window | +| Roadmap | M2 "responsive device presets"; M3 external Chrome with a `WebSocketTransport`; story U14 | Setting a design's width uses the same emulation as device presets. Phase 5 below is M3 | + +## 3. Verified + +These were probed in Chromium 141 (Playwright's build, headless) on a page served with `Content-Security-Policy: default-src 'self'; img-src 'self'`. + +| Fact (verified) | Consequence | +|---|---| +| An `` with a `data:` URL is blocked by that CSP, even when an isolated world (`Page.createIsolatedWorld`) creates it | The overlay can't be an `` or a CSS background | +| In the same isolated world, `createImageBitmap(new Blob([bytes]))` drawn on a `` works under that CSP. With `pointer-events: none`, the page still gets the pointer (`elementFromPoint` returns the page's element under it) | The overlay is a canvas, and the page stays usable under it | +| The page's own scripts see that canvas (isolated worlds share the DOM) | Add it to ``, not ``, and put it back if the page removes it. The docs say the page can see it | +| `Page.captureScreenshot` with `captureBeyondViewport: true`, clipped to `Page.getLayoutMetrics().cssContentSize`, returns the whole page: 800×3000 from an 800×600 viewport. The viewport and scroll position are unchanged afterwards | Full-page captures need no resizing | +| `Emulation.setDeviceMetricsOverride({ width: 1440, height: 900, deviceScaleFactor: 2 })` gives `innerWidth` 1440 and `devicePixelRatio` 2, and a capture of 2880×1800 | Before comparing, the page can take a design's width and density | +| Chromium started with `--remote-debugging-port=0 --user-data-dir=` writes `/DevToolsActivePort` (the port, then `/devtools/browser/`). `/json/list` lists its tabs (id, title, URL) | The app finds a browser it launched through that file, without reading the browser's output, and lists its tabs | + +**Not probed here**, since this environment has no Firefox or WebKit: + +- Firefox's BiDi interception and reading response bodies; +- WebKit through Playwright; +- `Page.captureScreenshot` through Electron's `debugger` on a `WebContentsView`, which should behave as in Chromium since it's the same CDP; +- device emulation inside the docked view. + +Each phase that relies on one of these starts by probing it. + +## 4. Proposal + +### 4.1 The browsers on this computer + +| System | Where installed browsers are found | Icon | +|---|---|---| +| Linux | `.desktop` files whose `Categories` include `WebBrowser` or whose `MimeType` has `x-scheme-handler/https`. They are in `/usr/share/applications`, `~/.local/share/applications`, and the Flatpak and Snap exports. The launch command is the `Exec=` line without its field codes | `Icon=`, looked up in the icon theme's folders (`hicolor`, largest PNG) | +| macOS | The apps under `/Applications` and `~/Applications` whose bundle id is a known browser's (`com.google.Chrome`, `com.apple.Safari`, `company.thebrowser.Browser`, `org.mozilla.firefox`, `com.microsoft.edgemac`, `com.brave.Browser`, …) | `app.getFileIcon(appPath)` | +| Windows | `reg query` of `HKLM` and `HKCU\SOFTWARE\Clients\StartMenuInternet\*` (`shell\open\command`) | `app.getFileIcon(exePath)` | + +- **What each browser records:** an id, name, version (where it's cheap to read), launch command and icon. + - It also records an **engine**, from known ids: `chromium`, `gecko`, `webkit` or `unknown`. The engine decides what the app can do with the browser. +- **Scanning:** at most once a minute, when a menu opens. +- **Add a browser…** (the dashed slot) takes an executable. That covers portable and preview builds such as Chrome Canary or Firefox Nightly. +- **No brand logos in the repository.** Icons come from the system, which avoids trademark questions and matches what the user knows. + +### 4.2 Opening the page in one + +| Level | What happens | Browsers | +|---|---|---| +| **Open** | The current URL opens in the browser, with your everyday profile. Nothing is changed | All | +| **Open with my changes** | The app launches the browser with a profile of its own, and serves the active workspace's overrides and rules there. As with its own page, it reloads that browser's tabs on the site after a save | Chromium browsers (phase 5), Firefox (phase 7) | +| **Engine builds** | Playwright's WebKit (and, if useful, its Firefox) is downloaded when first used, and driven with your changes | WebKit on every system (phase 8) | + +**Chromium browsers (phase 5):** + +1. **Launch** ` --user-data-dir=/browsers/ --remote-debugging-port=0 --no-first-run --no-default-browser-check --disable-features= `. +2. **Connect:** wait for `DevToolsActivePort`, then `CdpConnection.connect`. `Target.setDiscoverTargets` reports tabs as they open and close. +3. **Serve:** each page tab gets `attachToPage` and a `PageInterception` running the active workspace's overrides and rules, with the app's settings. +4. **Next launch:** a browser still running (its `DevToolsActivePort` answers `/json/version`) is reused. + +Notes: + +- **Why a profile of its own:** Chrome 136 and later ignore the debugging port on the everyday profile. The profile keeps its logins between launches. +- **Snap and Flatpak** browsers can only write inside their sandbox (Snap: under `~/snap//`), so the profile folder goes there. +- **Your own Chrome (U14):** Chrome 144 and later let the user allow remote debugging of the running browser from `chrome://inspect`, and the browser asks before a client connects. That needs checking. If it holds, the same connection works on the everyday profile. +- **What drives a launched browser's tabs:** at first, only overrides, rules and settings. The console, Network and inspector stay on the app's page. A page picker for the panels can come later. + +**Firefox (phase 7):** + +- **Launch** `firefox --profile --no-remote --new-instance --remote-debugging-port=0 `. Firefox prints `WebDriver BiDi listening on ws://…`. +- **Commands used:** + - `session.new`; + - `network.addIntercept` (phases `beforeRequestSent` and `responseStarted`, with URL patterns); + - `network.provideResponse`, `continueResponse` and `failRequest`; + - `script.addPreloadScript` (the SRI guard, the overlay); + - `browsingContext.captureScreenshot` (`origin: 'document'` for the full page); + - `browsingContext.setViewport`. +- **Reading the upstream body** needs `network.addDataCollector` and `network.getData`, which are newer: probe which Firefox has them. Two features need that body: redeploy detection and patch mode. Without it they stay off in Firefox, and the menu says so. +- **A new `BidiServer`** reuses `OverrideMatcher`, `requestMatches`, `engine/rules`, `engine/transform` and `patchLive`. It doesn't share the CDP event flow. + +**Safari:** + +- **Open only.** `safaridriver` (WebDriver, macOS, after Develop › Allow Remote Automation) can capture Safari but can't change a response. +- **The menu says so:** "Safari: open only. Check WebKit with your changes →". + +**WebKit (phase 8):** + +- **The build:** Playwright's WebKit, downloaded into `/browsers/playwright` on first use, after the user agrees to the download size. It's driven with `playwright-core`, whose `route` answers requests in WebKit too. +- **The dependency:** `playwright-core` moves from the dev dependencies to the app's, pinned, since each build matches one version of it. + +### 4.3 The browser menu + +- **Where:** in the preview toolbar, left of the address bar, in both placements. The button shows up to four installed browsers' icons, as in the reference. +- **The popover:** the page freezes into its still, as for any popover over it. It holds: + - **Search browser tabs…**, and a gear that opens Settings › Browsers (which ones to show, their profile folders, Add a browser…). + - One group per browser: its icon, name and version, **Open**, and **Open with my changes** where its engine allows it. + - For a browser the app launched, its tabs (favicon, title, host). A click brings the tab forward (`Target.activateTarget`, `browsingContext.activate`). **Open here** loads the tab's address in the app. + - A dot on a browser the app is driving. +- **The palette:** "Open in Firefox", "Open in Chrome with my changes", and so on. +- **The status bar:** how many outside tabs are getting the workspace's changes. +- **Tabs of browsers the app didn't launch** (the reference lists Arc's) come later (phase 9), and depend on the system: + - macOS: by AppleScript, for Safari, Chrome, Arc, Edge and Brave. macOS asks for Automation permission first. + - Firefox: from its session file, `sessionstore-backups/recovery.jsonlz4`, on any system. + - Chrome elsewhere: only with remote debugging turned on. + - They're used to bring an address from your everyday browser into a workspace. + +### 4.4 Captures and designs (shots) + +```ts +interface Shot { + id: string; // 8 hex chars + kind: 'capture' | 'design'; + name: string; // "shop.test-cart-1440-full.png"; editable + width: number; height: number; // pixels in the file + scale: number; // device pixels per CSS pixel: 2 for a 2× capture or a 2× design export + pageUrl?: string; // a capture's page + browser?: { id: string; name: string; version: string }; // a capture's browser ('app' for the app's own page) + viewport?: { width: number; height: number }; // CSS pixels, when captured + area?: 'viewport' | 'page' | 'element'; + group?: string; // captures taken together in every browser (§4.6) + createdAt: number; updatedAt: number; +} +``` + +- **Storage:** `shots.json` (`{ version: 1, shots: (Shot & { workspaceId })[] }`), `shots/.`, and a 256 px JPEG thumbnail `shots/.thumb.jpg` made with `nativeImage.resize`. Everything is written atomically. +- **Per workspace.** A workspace's shots are deleted before the workspace itself, for the same reason its actions and overrides go first. +- **Capturing:** the viewport, the full page, or one element (the inspector's picker, then `DOM.getBoxModel` for the clip), in the app's page or in a browser it drives. Before capturing, the app can: + - wait for `document.fonts.ready` and for 500 ms without requests; + - hide the design overlay; + - optionally, pause animations (`Animation.setPlaybackRate` 0). +- **Limits:** a full page is capped at 16 384 device pixels tall, Chromium's largest texture; stitching tiles comes later. A file is at most 50 MB, and a workspace holds at most 500 shots. +- **Importing a design:** **Import design…** (several files), dropping files on the list, or pasting (Figma's Copy as PNG). PNG, JPEG and WebP are accepted. The renderer decodes each one to check and measure it, and the file is kept as it came. + - A design's **scale** comes from `@2x` or `@3x` in its name, else 2 when it is wider than 2000 px, else 1. It can be changed on the shot's page. + - Importing a Figma frame by its link, through Figma's API with a personal token, comes later. + +### 4.5 The shots list + +- **Where:** at the right end of the preview toolbar, a button shows the latest shots' thumbnails, stacked as in the reference. Its popover freezes the page, like the browser menu. +- **The popover:** + - A **Search shots…** field (name, host, browser). + - Segments **All · Captures · Designs**. The reference's "Exports" become designs. + - Rows with a thumbnail, a name, and a line: "Capture · Firefox 131 · 1440 × 900 · 15 h ago" or "Design · 1440 wide · 2 days ago". A chevron opens the shot's page. + - In the header: **Capture** (a split button: the viewport, the full page, an element, in every browser) and **Import design…**. + - A row's menu: Compare with the page, Compare with…, Put over the page, Rename, Copy image, Save as…, Show in folder, Delete. +- **The palette:** Capture the page, Capture the full page, Import a design, Open a shot… +- **A shot's page** is an editor tab of its own kind, like What's New. It shows: + - the image, at Fit, 100 %, or zoomed in to a pixel grid; + - the colour of the pixel under the pointer; + - the image's size and scale, and where it came from (address, browser, viewport). + +### 4.6 Pixel-perfect checks + +**Over the live page (the overlay):** **Put over the page**. + +- **Width:** the page takes the design's width in CSS pixels (width ÷ scale) through device emulation. In the docked view, the page is scaled down to fit, as in DevTools' device mode; that needs probing in Electron. +- **Drawing it:** the design is drawn on a canvas in an isolated world (`Page.addScriptToEvaluateOnNewDocument` with a `worldName`, and the image sent once the frame has loaded). It either scrolls with the page, absolute on the document, or stays fixed to the viewport. +- **Controls:** a bar under the preview toolbar, never inside the page. It has opacity, **Difference** blending, **Invert**, x/y nudges (arrow keys while the bar has focus, Shift for 10 px), Scroll with the page / Fixed, Hide (H) and Remove. +- **In other browsers:** the same script works in a Chromium browser the app drives, and in Firefox through a BiDi preload script. + +**On stills (the compare page):** two shots, or a shot and a fresh capture at the design's width. + +- **Modes:** **Side by side** (scrolled together), **Swipe**, **Onion skin**, and **Difference**. Difference shows differing pixels in red over a faded base, with the share of pixels that differ and boxes around the differing areas to step through. +- **Alignment:** top left, both scaled to CSS pixels, with x/y offsets. +- **The diff** runs in a worker. It's a pixelmatch-style compare: YIQ colour distance with a threshold, ignoring anti-aliased pixels. + +**Across browsers:** **Capture in every browser**. + +- **What it does:** it covers the app's page and every browser the app drives. Each gets the same address, viewport and density, and waits for the load, fonts and 500 ms without requests. Then each captures the full page. +- **The result** is saved as one group. A compare page shows the group as a grid against a baseline (the app's capture, or a design), with each capture's share of differing pixels. That's the "is something broken in Firefox?" check, in one click. + +## 5. Code layout sketch + +``` +src/main/browsers/ findBrowsers/ (linux, mac, windows), openInBrowser, DrivenBrowsers (launched ones: connection, tabs, engines), + chromium/ (launch, readDevToolsActivePort, ChromiumDriver: targets → PageInterception), + firefox/ (launch, BidiConnection, BidiServer), webkit/ (Playwright, phase 8), registerBrowserIpc +src/main/shots/ ShotStore (shots.json, files, thumbnails), captureOverCdp (viewport, page, element), importDesign, registerShotIpc +src/main/overlay/ the injected overlay source, DesignOverlay per page (CDP and BiDi adapters) +src/shared/ shots/shotSchema, types (Shot, BrowserInfo), IPC channels, events browsers-changed and shots-changed +renderer entities/ browser (installed and driven, their tabs), shot (the list) +renderer features/browser/ open-in-browser, drive-browser +renderer features/shot/ capture, import-design, compare (with the diff worker), overlay (the bar's state) +renderer widgets/ browser-menu, shots-menu, shot-page (viewer and compare), overlay-bar; page-preview gains the two buttons +``` + +- **Why the new slices are grouped** (`features/browser/`, `features/shot/`): steiger allows at most 20 ungrouped feature slices, and the app is close to that limit. +- **Structure:** every new file follows the code-structure rules. Large parts (`BidiServer`, `ShotStore`) start as folders. + +## 6. To-do list + +Each phase ends in a pull request of its own. Each updates SPEC, the CHANGELOG and, where it shows, the README. + +### Phase 1: Open in another browser +- [ ] `findBrowsers` for Linux, macOS and Windows, each tested on fixtures (`.desktop` files, `reg query` output, app folders). +- [ ] Icons: the Linux icon theme lookup, and `app.getFileIcon` on macOS and Windows. Each is cached as a data URL. +- [ ] `openInBrowser(id, url)`: a detached spawn, `open -b` on macOS, `gtk-launch`, or the `Exec=` line on Linux. +- [ ] IPC (`listBrowsers`, `openInBrowser`), zod-checked, and the `browsers-changed` event. +- [ ] `entities/browser`, and the browser button with its popover in `PreviewToolbar`, in both placements (search, one row per browser, **Open**). +- [ ] Palette items, and Settings › Browsers: which ones to show, and Add a browser…. +- [ ] Tests: unit tests for the parsers; e2e with a fake browser (a script that writes the address it got to a file). + +### Phase 2: Captures +- [ ] `captureOverCdp`: the viewport, the full page (with the height cap) and an element, on a `CdpTransport`. Probe it on the app's `WebContentsView` first. +- [ ] `ShotStore`: files, thumbnails, `shots.json`, limits, removal with the workspace (in `WorkspaceController`'s delete order). +- [ ] IPC: `capture`, `listShots`, `renameShot`, `deleteShot`, `showShotInFolder`, `saveShotAs`, `copyShot`. +- [ ] `entities/shot`, and the shots button (stacked thumbnails) and its popover: search, segments, rows and row menu. +- [ ] The shot page: an editor tab kind with the viewer (zoom, pixel grid, colour under the pointer, details). +- [ ] An element capture from the inspector's picker, and palette items. +- [ ] Tests: unit tests for the store; integration tests for full-page and element clips in Chromium; e2e: capture, list, rename, delete, restart. + +### Phase 3: Designs and the compare page +- [ ] **Import design…**, dropping files, pasting: decode and measure in the renderer, guess the scale, keep the file. +- [ ] The compare page: side by side, swipe, onion skin, difference, and offsets. +- [ ] The diff worker: YIQ distance, anti-aliasing, the share of pixels that differ, differing areas. Measure it on a 1440×10 000 page. +- [ ] "Compare with the page": capture at the design's width (`Emulation.setDeviceMetricsOverride`), then compare, then clear the emulation. +- [ ] Tests: unit tests for the diff (identical, shifted and anti-aliased images); e2e: import a PNG, compare it with a capture of a fixture page, check the share of differing pixels. + +### Phase 4: The overlay on the live page +- [ ] The injected overlay (canvas, `pointer-events: none`, scroll or fixed, put back if removed), installed in an isolated world on every load. +- [ ] `DesignOverlay` on CDP: install, update (opacity, blend, offset, invert, hidden) and remove it. Updates are small messages, and the image is sent once. +- [ ] Device emulation for the design's width in the docked view and in the website's own window (probe the scaling first). This also delivers M2's device presets, if wanted. +- [ ] The overlay bar and its keys, and hiding the overlay while capturing. +- [ ] Tests: e2e on a fixture page with a strict CSP: the canvas is there, the page still gets clicks, it survives a reload, and it's gone after Remove. + +### Phase 5: Chromium browsers with your changes (M3) +- [ ] `launchChromium` (the flags above, a profile under `userData`, the Snap and Flatpak paths) and `readDevToolsActivePort`; reuse a browser that's still running. +- [ ] `ChromiumDriver`: target discovery, `attachToPage` and a `PageInterception` per tab, following workspace switches and override and rule changes. It reloads after saves, cleans up when the browser closes, and survives a lost connection. +- [ ] Tabs in the browser menu (search, bring forward, Open here), the dot, the status bar count, and **Open with my changes**. +- [ ] Captures and the overlay in those tabs, through the same CDP code. +- [ ] Probe Chrome 144's `chrome://inspect` permission for the everyday profile (U14). +- [ ] Tests: integration with the Chromium harness (an override served in a launched browser's tab, a rule applied, a reload after a save); e2e with Playwright's Chromium as the outside browser. + +### Phase 6: Capture in every browser +- [ ] One capture in each driven browser at the same address, viewport and density, with settle rules; saved as a group. +- [ ] The group's compare grid against a baseline, with the share of differing pixels per browser. +- [ ] Tests: e2e with the app's page and one launched Chromium. + +### Phase 7: Firefox with your changes +- [ ] Probe BiDi in the current Firefox: `addIntercept`, `provideResponse` with a body, headers at `responseStarted`, `addDataCollector` and `getData`, and preload scripts in cross-site iframes. Write down what holds. +- [ ] `launchFirefox` (a profile with `user.js` prefs for no first run and no updates), and `BidiConnection`. +- [ ] `BidiServer`: patterns from the overrides and rules, the request and response stages, and the plain engine functions. Redeploy detection and patch mode only where bodies can be read. +- [ ] Tabs (`browsingContext.getTree`), captures (`captureScreenshot`), the overlay (preload script), the viewport (`setViewport`). +- [ ] Tests: integration tests against Firefox, skipped where it isn't installed; add Firefox to one CI job. + +### Phase 8: WebKit +- [ ] Decide between Playwright's WebKit (downloaded on use) and nothing, after measuring the download and checking the licence text to ship. +- [ ] The download with progress, and removing it from Settings. +- [ ] A driver on `playwright-core`: `route` for overrides and rules, `screenshot`, viewport, and `addInitScript` for the overlay. +- [ ] Tests: skipped where the build isn't there. + +### Phase 9: Later +- [ ] Tabs of everyday browsers: AppleScript on macOS, Firefox's session file everywhere. +- [ ] Figma frames by link (a personal token, kept with `safeStorage`). +- [ ] Stitching full pages taller than the texture limit. +- [ ] The console, Network and inspector for an outside tab. + +## 7. Limits and risks + +| Risk | Mitigation | +|---|---| +| Safari can't be served changes | Say so in the menu, and offer WebKit (phase 8) | +| Firefox can't read response bodies before some version | Redeploy detection and patch mode stay off there, and the menu says so | +| Snap and Flatpak sandboxes | Profile folders inside the sandbox. If a launch fails, show the browser's own output | +| The page's scripts can see or remove the overlay | Add it to `` and put it back; documented | +| Sticky headers and lazy images in full-page captures | A fixed element appears once, at the top. An option scrolls through the page first, so lazy images load | +| Fonts differ between systems, so cross-system diffs are noise | Compare captures taken on one machine; the compare page names each capture's browser and system | +| Big images use a lot of memory | Caps (height, file size, shots per workspace); the diff runs in a worker on bitmaps | + +## 8. Decisions to make + +| Question | Recommendation | Alternative | +|---|---|---| +| Build order | Phases 1 → 2 → 3 → 4 first: useful on their own, and they need no outside browser. Then 5, 6, 7, 8 | Phase 5 first, since M3 and U14 are already on the roadmap | +| Where shots live | Per workspace, like overrides and actions | One library for all workspaces, filtered by host | +| The shots list | A toolbar popover (the reference), plus the palette | A rail view listing shots, with more room for groups | +| The diff | Our own small compare in a worker | `pixelmatch` (ISC licence, no dependencies) | +| WebKit | Playwright's build, downloaded on use | Only "Open in Safari" on macOS; no WebKit with changes | +| Profiles of driven browsers | One per browser under `userData`, logins kept | A new temporary profile each launch | diff --git a/docs/SPEC.md b/docs/SPEC.md index f519807..152d59e 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -51,6 +51,11 @@ A desktop app where you enter a website's URL, see every script, stylesheet and | U31 | I see which code sent a request: the stack of the script, traced to the originals; and on a component's page, the requests sent from its file | ✅ (§6.10, §6.13) | | U32 | I keep a state value I set on the Component page as an action, which sets it again whenever I run it (after a reload too) | ✅ (§6.13) | | U33 | I see the renders I recorded by component: how often each rendered, mounted or was skipped, how long its own renders took, and why | ✅ (§6.14) | +| U34 | I open the page in another browser installed on this computer (Chrome, Firefox, Safari, Edge…) from beside the address bar, to see whether something is broken there | 🔜 M5 ([research](BROWSERS_RESEARCH.md)) | +| U35 | I open it in a Chromium browser or Firefox with my overrides and rules applied, and search the tabs the app opened there | 🔜 M5 | +| U36 | I capture the page (the viewport, the whole page or one element), in the app or in another browser, and find my captures in a list at the end of the toolbar | 🔜 M5 | +| U37 | I import a design and check the page against it pixel by pixel: laid over the live page, or side by side, swiped, faded or as a difference | 🔜 M5 | +| U38 | I capture the page in every browser at once and see where they differ from each other or from the design | 🔜 M5 | ## 3. Architecture @@ -804,7 +809,7 @@ The package manager is asked rather than electron-builder's `resources/package-t - Docked/undocked page view, and responsive device presets. **M3: Your own Chrome, and distribution** -- External Chrome mode: launch Chrome with a dedicated `--user-data-dir` plus `--remote-debugging-port`, or connect to a running one; `WebSocketTransport` implementing `CdpTransport`; one engine per tab. +- External Chrome mode: launch Chrome with a dedicated `--user-data-dir` plus `--remote-debugging-port`, or connect to a running one; `WebSocketTransport` implementing `CdpTransport`; one engine per tab. Planned as phase 5 of M5 ([research](BROWSERS_RESEARCH.md)). - ✅ Installers with electron-builder, built and smoke-tested on all three systems by the release workflow (§10). - ✅ Update notifications, What's New, and installing updates on Windows and with the AppImage, `.deb` and `.rpm` (§10.1). - Signed and notarized builds, and with them installing updates in place on macOS. @@ -815,6 +820,15 @@ The package manager is asked rather than electron-builder's `resources/package-t - ✅ Console panel for the page and every frame in it (§6.7), and actions: code kept to run in a frame with one click (§6.9). The Actions panel also goes into a window of its own. Still to come ([research](ACTIONS_RESEARCH.md)): parameters in actions, sending a message to a frame without writing code, a message log of `postMessage` between frames, waiting for a log, scenarios, showing a frame in the page, reloading or retargeting one frame, network and storage per frame. - Component inspector ([research](INSPECTOR_RESEARCH.md)): ✅ the page stack, which UI library, framework, state library and bundler each frame runs (§6.12); ✅ the React or Vue component behind an element, its original file (production builds too, through source maps), props, state, context, handlers and chain (§6.13); ✅ the Components tree, setting state, hook names from originals and source-map coverage (§6.12, §6.13); ✅ Renders, why each React component rendered (§6.14). ✅ Angular (development and production builds), Vue 2 and web components, the listeners on an element, and **Load a source map…** for a bundle whose map isn't published (§6.8, §6.13). ✅ Store actions (Redux, Redux Toolkit, NgRx, Zustand, Pinia, Vuex) with what each changed and the line that dispatched it (§6.15); who sent a request, and a component's requests (§6.10, §6.13); a state value kept as an action (§6.13); renders by component (§6.14). Still to come: Vue's renders, a data-flow view (what comes into a component and what it sets or calls), and async stacks for requests and actions. +**M5: Other browsers, captures and designs** ([research](BROWSERS_RESEARCH.md)) +- Open the page in another installed browser: found per system, with their own icons, in a menu beside the address bar and in the palette. +- Captures of the viewport, the whole page or one element, kept per workspace and listed in a menu at the end of the toolbar, with search and Captures/Designs. +- Designs imported from files or the clipboard, and a compare page: side by side, swipe, onion skin and difference, with the share of pixels that differ. +- A design laid over the live page (a canvas in an isolated world, so a strict CSP doesn't block it), at the design's width. +- Chromium browsers with your overrides and rules (M3's external Chrome), and their tabs in the menu. +- One capture in every browser, compared in a grid. +- Firefox with your changes over WebDriver BiDi, and WebKit through Playwright's build. + ## 12. Risks and open questions | Risk | Mitigation | From 8d018675046b1182bbaffecbf85332276e1f3303 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 19:58:07 +0000 Subject: [PATCH 02/19] Open the page in the other browsers installed on this computer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A menu beside the address bar, in the editor and in the website's own window, lists the browsers found the system's way: Linux launchers (with their icons from the icon theme), apps in the Applications folders on macOS, and the registry's list on Windows. Choosing one opens the page there, with its everyday profile. Settings gets a Browsers section to stop offering one, or to add one by picking its program, and the palette gets an "Open in …" item per browser. --- src/main/ActionsWindow/ActionsWindow.ts | 6 +- src/main/PageWindow/PageWindow.ts | 9 +- src/main/PageWindow/constants.ts | 6 + .../BrowserRegistry/BrowserRegistry.ts | 123 ++++++++ .../browsers/BrowserRegistry/addedBrowser.ts | 10 + .../BrowserRegistry/checkBrowserPath.ts | 14 + src/main/browsers/BrowserRegistry/index.ts | 1 + src/main/browsers/BrowserRegistry/infoOf.ts | 7 + src/main/browsers/browserIcon/browserIcon.ts | 29 ++ src/main/browsers/browserIcon/index.ts | 1 + src/main/browsers/constants.ts | 38 +++ src/main/browsers/engineOf.ts | 10 + .../browsers/findBrowsers/findBrowsers.ts | 16 + src/main/browsers/findBrowsers/index.ts | 1 + .../browsers/findBrowsers/linux/commandOf.ts | 18 ++ .../browsers/findBrowsers/linux/constants.ts | 41 +++ .../findBrowsers/linux/desktopDataDirs.ts | 10 + .../findBrowsers/linux/desktopFiles.ts | 18 ++ .../findBrowsers/linux/findLinuxBrowsers.ts | 30 ++ .../findBrowsers/linux/findLinuxIcon.ts | 27 ++ .../findBrowsers/linux/isBrowserEntry.ts | 14 + .../findBrowsers/linux/labelDuplicates.ts | 12 + .../findBrowsers/linux/parseDesktopEntry.ts | 28 ++ .../findBrowsers/linux/programExists.ts | 17 ++ .../browsers/findBrowsers/linux/programOf.ts | 11 + .../findBrowsers/linux/readDesktopBrowser.ts | 32 ++ .../browsers/findBrowsers/linux/splitExec.ts | 31 ++ .../browsers/findBrowsers/mac/constants.ts | 8 + .../findBrowsers/mac/findMacBrowsers.ts | 24 ++ .../findBrowsers/windows/constants.ts | 18 ++ .../windows/findWindowsBrowsers.ts | 35 +++ .../findBrowsers/windows/parseRegQuery.ts | 19 ++ .../findBrowsers/windows/programOfCommand.ts | 9 + .../browsers/findBrowsers/windows/types.ts | 5 + src/main/browsers/index.ts | 2 + src/main/browsers/launchEnv.ts | 9 + src/main/browsers/readVersion/constants.ts | 14 + src/main/browsers/readVersion/index.ts | 1 + src/main/browsers/readVersion/readVersion.ts | 24 ++ src/main/browsers/runProgram.ts | 8 + src/main/browsers/startBrowser.ts | 16 + src/main/browsers/types.ts | 30 ++ src/main/ipc/registerBrowserIpc.ts | 38 +++ src/main/ipc/registerIpc.ts | 4 +- src/main/ipc/types.ts | 3 + src/main/launch/constants.ts | 2 + src/main/launch/createWindow.ts | 9 +- src/main/launch/openStores.ts | 6 +- src/main/launch/types.ts | 2 + src/main/store/BrowserStore/BrowserStore.ts | 50 ++++ src/main/store/BrowserStore/constants.ts | 12 + src/main/store/BrowserStore/index.ts | 2 + .../BrowserStore/sanitizeBrowserPrefs.ts | 19 ++ src/main/store/BrowserStore/types.ts | 17 ++ src/main/windows/forwardEvent.ts | 8 + src/main/windows/index.ts | 1 + src/preload/index.ts | 6 + .../src/app/model/bridge/appEventHandlers.ts | 2 + .../src/app/model/bridge/startBridge.ts | 4 + .../pageWindowEventHandlers.ts | 2 + .../startPageWindowBridge.ts | 2 + .../src/app/model/page-window-bridge/types.ts | 3 +- src/renderer/src/entities/browser/index.ts | 2 + .../src/entities/browser/model/store/index.ts | 3 + .../model/store/selectShownBrowsers.ts | 5 + .../src/entities/browser/model/store/types.ts | 10 + .../browser/model/store/useBrowserStore.ts | 9 + .../src/entities/browser/ui/BrowserIcon.tsx | 17 ++ .../features/browser/manage-browsers/index.ts | 1 + .../manage-browsers/model/addBrowser.ts | 12 + .../browser/manage-browsers/model/index.ts | 3 + .../manage-browsers/model/removeBrowser.ts | 12 + .../manage-browsers/model/showBrowser.ts | 12 + .../manage-browsers/ui/BrowserSettingRow.tsx | 25 ++ .../manage-browsers/ui/BrowserSettings.tsx | 23 ++ .../features/browser/open-in-browser/index.ts | 2 + .../browser/open-in-browser/model/index.ts | 3 + .../open-in-browser/model/loadBrowsers.ts | 8 + .../open-in-browser/model/matchesBrowser.ts | 10 + .../open-in-browser/model/openInBrowser.ts | 13 + .../open-in-browser/ui/BrowserCluster.tsx | 18 ++ .../open-in-browser/ui/BrowserMenu.tsx | 62 ++++ .../open-in-browser/ui/BrowserMenuButton.tsx | 47 +++ .../browser/open-in-browser/ui/BrowserRow.tsx | 27 ++ .../browser/open-in-browser/ui/constants.ts | 5 + .../pages/editor/ui/EditorPage/EditorPage.tsx | 2 +- .../command-palette/ui/AppCommandPalette.tsx | 9 +- .../command-palette/ui/browserItems.ts | 19 ++ .../ui/PagePreview/PagePreview.tsx | 4 +- .../ui/PagePreview/PreviewToolbar.tsx | 8 +- .../page-preview/ui/PagePreview/types.ts | 2 + .../settings-panel/ui/SettingsPanel.tsx | 4 +- src/shared/constants.ts | 6 + src/shared/ipcChannels.ts | 6 + src/shared/types/api.ts | 3 +- src/shared/types/browsers.ts | 20 ++ src/shared/types/browsersApi.ts | 15 + src/shared/types/events.ts | 3 + src/shared/types/index.ts | 3 + test/e2e/browsers.e2e.test.ts | 150 ++++++++++ test/unit/browsers.test.ts | 277 ++++++++++++++++++ 101 files changed, 1811 insertions(+), 23 deletions(-) create mode 100644 src/main/browsers/BrowserRegistry/BrowserRegistry.ts create mode 100644 src/main/browsers/BrowserRegistry/addedBrowser.ts create mode 100644 src/main/browsers/BrowserRegistry/checkBrowserPath.ts create mode 100644 src/main/browsers/BrowserRegistry/index.ts create mode 100644 src/main/browsers/BrowserRegistry/infoOf.ts create mode 100644 src/main/browsers/browserIcon/browserIcon.ts create mode 100644 src/main/browsers/browserIcon/index.ts create mode 100644 src/main/browsers/constants.ts create mode 100644 src/main/browsers/engineOf.ts create mode 100644 src/main/browsers/findBrowsers/findBrowsers.ts create mode 100644 src/main/browsers/findBrowsers/index.ts create mode 100644 src/main/browsers/findBrowsers/linux/commandOf.ts create mode 100644 src/main/browsers/findBrowsers/linux/constants.ts create mode 100644 src/main/browsers/findBrowsers/linux/desktopDataDirs.ts create mode 100644 src/main/browsers/findBrowsers/linux/desktopFiles.ts create mode 100644 src/main/browsers/findBrowsers/linux/findLinuxBrowsers.ts create mode 100644 src/main/browsers/findBrowsers/linux/findLinuxIcon.ts create mode 100644 src/main/browsers/findBrowsers/linux/isBrowserEntry.ts create mode 100644 src/main/browsers/findBrowsers/linux/labelDuplicates.ts create mode 100644 src/main/browsers/findBrowsers/linux/parseDesktopEntry.ts create mode 100644 src/main/browsers/findBrowsers/linux/programExists.ts create mode 100644 src/main/browsers/findBrowsers/linux/programOf.ts create mode 100644 src/main/browsers/findBrowsers/linux/readDesktopBrowser.ts create mode 100644 src/main/browsers/findBrowsers/linux/splitExec.ts create mode 100644 src/main/browsers/findBrowsers/mac/constants.ts create mode 100644 src/main/browsers/findBrowsers/mac/findMacBrowsers.ts create mode 100644 src/main/browsers/findBrowsers/windows/constants.ts create mode 100644 src/main/browsers/findBrowsers/windows/findWindowsBrowsers.ts create mode 100644 src/main/browsers/findBrowsers/windows/parseRegQuery.ts create mode 100644 src/main/browsers/findBrowsers/windows/programOfCommand.ts create mode 100644 src/main/browsers/findBrowsers/windows/types.ts create mode 100644 src/main/browsers/index.ts create mode 100644 src/main/browsers/launchEnv.ts create mode 100644 src/main/browsers/readVersion/constants.ts create mode 100644 src/main/browsers/readVersion/index.ts create mode 100644 src/main/browsers/readVersion/readVersion.ts create mode 100644 src/main/browsers/runProgram.ts create mode 100644 src/main/browsers/startBrowser.ts create mode 100644 src/main/browsers/types.ts create mode 100644 src/main/ipc/registerBrowserIpc.ts create mode 100644 src/main/store/BrowserStore/BrowserStore.ts create mode 100644 src/main/store/BrowserStore/constants.ts create mode 100644 src/main/store/BrowserStore/index.ts create mode 100644 src/main/store/BrowserStore/sanitizeBrowserPrefs.ts create mode 100644 src/main/store/BrowserStore/types.ts create mode 100644 src/main/windows/forwardEvent.ts create mode 100644 src/renderer/src/entities/browser/index.ts create mode 100644 src/renderer/src/entities/browser/model/store/index.ts create mode 100644 src/renderer/src/entities/browser/model/store/selectShownBrowsers.ts create mode 100644 src/renderer/src/entities/browser/model/store/types.ts create mode 100644 src/renderer/src/entities/browser/model/store/useBrowserStore.ts create mode 100644 src/renderer/src/entities/browser/ui/BrowserIcon.tsx create mode 100644 src/renderer/src/features/browser/manage-browsers/index.ts create mode 100644 src/renderer/src/features/browser/manage-browsers/model/addBrowser.ts create mode 100644 src/renderer/src/features/browser/manage-browsers/model/index.ts create mode 100644 src/renderer/src/features/browser/manage-browsers/model/removeBrowser.ts create mode 100644 src/renderer/src/features/browser/manage-browsers/model/showBrowser.ts create mode 100644 src/renderer/src/features/browser/manage-browsers/ui/BrowserSettingRow.tsx create mode 100644 src/renderer/src/features/browser/manage-browsers/ui/BrowserSettings.tsx create mode 100644 src/renderer/src/features/browser/open-in-browser/index.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/index.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/matchesBrowser.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/openInBrowser.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/BrowserCluster.tsx create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/BrowserMenuButton.tsx create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/constants.ts create mode 100644 src/renderer/src/widgets/command-palette/ui/browserItems.ts create mode 100644 src/shared/types/browsers.ts create mode 100644 src/shared/types/browsersApi.ts create mode 100644 test/e2e/browsers.e2e.test.ts create mode 100644 test/unit/browsers.test.ts diff --git a/src/main/ActionsWindow/ActionsWindow.ts b/src/main/ActionsWindow/ActionsWindow.ts index a7f0c3f..62472d7 100644 --- a/src/main/ActionsWindow/ActionsWindow.ts +++ b/src/main/ActionsWindow/ActionsWindow.ts @@ -1,9 +1,8 @@ import { screen, type BrowserWindow, type WebContents } from 'electron'; import { ACTIONS_WINDOW_HASH } from '../../shared/constants'; -import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { ActionsWindowState, AppEvent } from '../../shared/types'; import { loadEditor } from '../launch/loadEditor'; -import { createAppWindow, DockOnClose, placeWindow, setUpWindow, syncMenuCheck, trackPlacement } from '../windows'; +import { createAppWindow, DockOnClose, forwardEvent, placeWindow, setUpWindow, syncMenuCheck, trackPlacement } from '../windows'; import { ACTIONS_WINDOW_MENU_ID, ACTIONS_WINDOW_SIZE, ACTIONS_WINDOW_TITLE, FORWARDED_EVENTS, MIN_ACTIONS_WINDOW_SIZE } from './constants'; import type { ActionsWindowDeps } from './types'; @@ -76,8 +75,7 @@ export class ActionsWindow { /** Hands the window's UI an event its panel shows; the editor's other events aren't its business. */ forward(event: AppEvent): void { - const win = this.win; - if (win && !win.isDestroyed() && FORWARDED_EVENTS.has(event.type)) win.webContents.send(IPC_CHANNEL.onEvent, event); + forwardEvent(this.win, FORWARDED_EVENTS, event); } /** The editor's window is gone (the app is quitting): this window goes too, and opens again next time. */ diff --git a/src/main/PageWindow/PageWindow.ts b/src/main/PageWindow/PageWindow.ts index b6f83a9..8208798 100644 --- a/src/main/PageWindow/PageWindow.ts +++ b/src/main/PageWindow/PageWindow.ts @@ -4,8 +4,8 @@ import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { AppEvent, PageState, Rect } from '../../shared/types'; import { loadEditor } from '../launch/loadEditor'; import { AddressBarFocus } from './AddressBarFocus'; -import { createAppWindow, DockOnClose, placeWindow, setUpWindow, syncMenuCheck, trackPlacement } from '../windows'; -import { DEFAULT_WINDOW_SIZE, MIN_WINDOW_SIZE, PAGE_WINDOW_MENU_ID, UNTITLED } from './constants'; +import { createAppWindow, DockOnClose, forwardEvent, placeWindow, setUpWindow, syncMenuCheck, trackPlacement } from '../windows'; +import { DEFAULT_WINDOW_SIZE, FORWARDED_EVENTS, MIN_WINDOW_SIZE, PAGE_WINDOW_MENU_ID, UNTITLED } from './constants'; import { toViewBounds } from './toViewBounds'; import type { PageWindowDeps } from './types'; @@ -84,6 +84,11 @@ export class PageWindow { this.focus?.request(); } + /** Sends the website window's UI an event its toolbar's menus show, if it has a window and hears that event. */ + forward(event: AppEvent): void { + forwardEvent(this.win, FORWARDED_EVENTS, event); + } + /** `sender` asked for the page's state: the website window's UI does that once it listens for events. */ listening(sender: WebContents): void { if (this.owns(sender)) this.focus?.uiReady(); diff --git a/src/main/PageWindow/constants.ts b/src/main/PageWindow/constants.ts index 0404612..4aae1df 100644 --- a/src/main/PageWindow/constants.ts +++ b/src/main/PageWindow/constants.ts @@ -1,3 +1,6 @@ +import { PAGE_WINDOW_EVENTS } from '../../shared/constants'; +import type { AppEvent } from '../../shared/types'; + /** A new website window's size, before it is fitted to its screen. */ export const DEFAULT_WINDOW_SIZE = { width: 1280, height: 900 }; @@ -9,3 +12,6 @@ export const UNTITLED = 'Website'; /** The View menu's item for the website window (its check mark follows where the website is). */ export const PAGE_WINDOW_MENU_ID = 'page-window'; + +/** The events the website window's UI hears besides its page's state and its shortcut (which go out on their own). */ +export const FORWARDED_EVENTS: ReadonlySet = new Set(PAGE_WINDOW_EVENTS); diff --git a/src/main/browsers/BrowserRegistry/BrowserRegistry.ts b/src/main/browsers/BrowserRegistry/BrowserRegistry.ts new file mode 100644 index 0000000..aa25e0c --- /dev/null +++ b/src/main/browsers/BrowserRegistry/BrowserRegistry.ts @@ -0,0 +1,123 @@ +import { randomBytes } from 'node:crypto'; +import { basename, extname } from 'node:path'; +import type { BrowserInfo } from '../../../shared/types'; +import { HTTP_URL } from '../../constants'; +import { browserIcon } from '../browserIcon'; +import { BROWSER_ID_PREFIX, SCAN_REUSE_MS } from '../constants'; +import { engineOf } from '../engineOf'; +import { findBrowsers } from '../findBrowsers'; +import { readVersion } from '../readVersion'; +import { startBrowser } from '../startBrowser'; +import type { BrowserRegistryDeps, FoundBrowser } from '../types'; +import { addedBrowser } from './addedBrowser'; +import { checkBrowserPath } from './checkBrowserPath'; +import { infoOf } from './infoOf'; + +/** Bytes of an added browser's id. */ +const ID_BYTES = 4; + +/** + * The browsers on this computer and the ones the user added: looked for at most once a minute, with their icons; + * their versions are read one at a time afterwards and announced (`browsers-changed`) once all are in. + */ +export class BrowserRegistry { + private found: FoundBrowser[] = []; + private scannedAt = Number.NEGATIVE_INFINITY; + private scanning: Promise | undefined; + private readingVersions = false; + private readonly icons = new Map(); + private readonly versions = new Map(); + + constructor(private readonly deps: BrowserRegistryDeps) {} + + async list(): Promise { + await this.scan(); + return this.infos(); + } + + /** A browser by id, looked for again first when the last scan is old. */ + async get(id: string): Promise { + await this.scan(); + const browser = this.all().find((b) => b.id === id); + if (!browser) throw new Error('That browser is no longer there'); + return browser; + } + + /** Opens an http(s) address in a browser, with its everyday profile. */ + async open(id: string, url: string): Promise { + if (!HTTP_URL.test(url)) throw new Error('Only http(s) pages open in another browser'); + await startBrowser(await this.get(id), [url]); + } + + /** Adds the program (or macOS app) at `path`, named after its file. */ + async add(path: string): Promise { + await checkBrowserPath(path); + const name = basename(path, extname(path)); + const added = { id: `${BROWSER_ID_PREFIX.added}${randomBytes(ID_BYTES).toString('hex')}`, name, path, engine: engineOf([path, name]) }; + await this.deps.prefs.add(added); + const browser = addedBrowser(added); + this.icons.set(browser.id, await browserIcon(browser)); + this.changed(); + void this.readVersions(); + return infoOf(browser, this.icons.get(browser.id), null, new Set()); + } + + async remove(id: string): Promise { + if (!this.deps.prefs.get().added.some((b) => b.id === id)) throw new Error('Only a browser you added can be removed'); + await this.deps.prefs.remove(id); + this.changed(); + } + + async setHidden(id: string, hidden: boolean): Promise { + await this.deps.prefs.setHidden(id, hidden); + this.changed(); + } + + private all(): FoundBrowser[] { + return [...this.found, ...this.deps.prefs.get().added.map(addedBrowser)]; + } + + private infos(): BrowserInfo[] { + const hidden = new Set(this.deps.prefs.get().hidden); + return this.all().map((b) => infoOf(b, this.icons.get(b.id), this.versions.get(b.id), hidden)); + } + + private changed(): void { + this.deps.send({ type: 'browsers-changed', browsers: this.infos() }); + } + + private async scan(): Promise { + if (Date.now() - this.scannedAt < SCAN_REUSE_MS) return; + this.scanning ??= (async () => { + const found = (await (this.deps.find ?? findBrowsers)().catch((): FoundBrowser[] => [])).sort((a, b) => a.name.localeCompare(b.name)); + const all = [...found, ...this.deps.prefs.get().added.map(addedBrowser)]; + await Promise.all(all.filter((b) => !this.icons.has(b.id)).map(async (b) => this.icons.set(b.id, await browserIcon(b)))); + this.found = found; + this.scannedAt = Date.now(); + })().finally(() => { + this.scanning = undefined; + }); + await this.scanning; + void this.readVersions(); + } + + /** One at a time (a browser's program can be slow to start); announced once, when any was read. */ + private async readVersions(): Promise { + if (this.readingVersions) return; + this.readingVersions = true; + let read = false; + try { + for (let next = this.unversioned(); next; next = this.unversioned()) { + this.versions.set(next.id, await readVersion(next)); + read = true; + } + } finally { + this.readingVersions = false; + } + if (read) this.changed(); + } + + private unversioned(): FoundBrowser | undefined { + return this.all().find((b) => !this.versions.has(b.id)); + } +} diff --git a/src/main/browsers/BrowserRegistry/addedBrowser.ts b/src/main/browsers/BrowserRegistry/addedBrowser.ts new file mode 100644 index 0000000..954c408 --- /dev/null +++ b/src/main/browsers/BrowserRegistry/addedBrowser.ts @@ -0,0 +1,10 @@ +import { OPEN_APP_FLAG, OPEN_COMMAND, APP_EXTENSION } from '../findBrowsers/mac/constants'; +import type { AddedBrowser } from '../../store/BrowserStore'; +import type { FoundBrowser } from '../types'; + +/** A browser the user added, as one found: its program started with the address (a macOS app through `open -a`). */ +export function addedBrowser({ id, name, path, engine }: AddedBrowser): FoundBrowser { + const macApp = process.platform === 'darwin' && path.endsWith(APP_EXTENSION); + const command = macApp ? [OPEN_COMMAND, OPEN_APP_FLAG, path] : [path]; + return { id, name, engine, command, urlAt: command.length, iconFile: null, app: path, program: macApp ? null : path, added: true }; +} diff --git a/src/main/browsers/BrowserRegistry/checkBrowserPath.ts b/src/main/browsers/BrowserRegistry/checkBrowserPath.ts new file mode 100644 index 0000000..aac8a87 --- /dev/null +++ b/src/main/browsers/BrowserRegistry/checkBrowserPath.ts @@ -0,0 +1,14 @@ +import { constants } from 'node:fs'; +import { access, stat } from 'node:fs/promises'; +import { APP_EXTENSION } from '../findBrowsers/mac/constants'; + +/** Rejects a path that isn't a program this system can start: an app bundle on macOS, else an executable file. */ +export async function checkBrowserPath(path: string): Promise { + const info = await stat(path).catch(() => null); + if (!info) throw new Error(`${path} isn't there`); + if (process.platform === 'darwin' && path.endsWith(APP_EXTENSION) && info.isDirectory()) return; + if (!info.isFile()) throw new Error(`${path} isn't a program`); + await access(path, constants.X_OK).catch(() => { + throw new Error(`${path} can't be run`); + }); +} diff --git a/src/main/browsers/BrowserRegistry/index.ts b/src/main/browsers/BrowserRegistry/index.ts new file mode 100644 index 0000000..7dfa50f --- /dev/null +++ b/src/main/browsers/BrowserRegistry/index.ts @@ -0,0 +1 @@ +export { BrowserRegistry } from './BrowserRegistry'; diff --git a/src/main/browsers/BrowserRegistry/infoOf.ts b/src/main/browsers/BrowserRegistry/infoOf.ts new file mode 100644 index 0000000..ec7a838 --- /dev/null +++ b/src/main/browsers/BrowserRegistry/infoOf.ts @@ -0,0 +1,7 @@ +import type { BrowserInfo } from '../../../shared/types'; +import type { FoundBrowser } from '../types'; + +/** What the UI is told of a browser: never its command or paths. */ +export function infoOf(browser: FoundBrowser, icon: string | null | undefined, version: string | null | undefined, hidden: ReadonlySet): BrowserInfo { + return { id: browser.id, name: browser.name, engine: browser.engine, version: version ?? null, icon: icon ?? null, added: browser.added, hidden: hidden.has(browser.id) }; +} diff --git a/src/main/browsers/browserIcon/browserIcon.ts b/src/main/browsers/browserIcon/browserIcon.ts new file mode 100644 index 0000000..cc208c2 --- /dev/null +++ b/src/main/browsers/browserIcon/browserIcon.ts @@ -0,0 +1,29 @@ +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { app, nativeImage } from 'electron'; +import { ICON_SIZE, MAX_SVG_ICON_BYTES } from '../constants'; +import type { FoundBrowser } from '../types'; + +const SVG = '.svg'; +const SVG_DATA_URL = 'data:image/svg+xml;base64,'; + +/** + * A browser's icon as a data URL: its icon file (a PNG scaled to `ICON_SIZE`, an SVG as it is), else what the system + * shows for its app or program (macOS, Windows); null when there is none. + */ +export async function browserIcon(browser: FoundBrowser): Promise { + try { + if (browser.iconFile) { + const bytes = await readFile(browser.iconFile); + if (extname(browser.iconFile) === SVG) return bytes.length <= MAX_SVG_ICON_BYTES ? `${SVG_DATA_URL}${bytes.toString('base64')}` : null; + const image = nativeImage.createFromBuffer(bytes); + return image.isEmpty() ? null : image.resize({ width: ICON_SIZE, height: ICON_SIZE, quality: 'best' }).toDataURL(); + } + // Linux's answer is the icon of the file's type (a generic program), which says nothing: the menu draws its own. + if (!browser.app || process.platform === 'linux') return null; + const image = await app.getFileIcon(browser.app, { size: 'large' }); + return image.isEmpty() ? null : image.toDataURL(); + } catch { + return null; + } +} diff --git a/src/main/browsers/browserIcon/index.ts b/src/main/browsers/browserIcon/index.ts new file mode 100644 index 0000000..85ebafb --- /dev/null +++ b/src/main/browsers/browserIcon/index.ts @@ -0,0 +1 @@ +export { browserIcon } from './browserIcon'; diff --git a/src/main/browsers/constants.ts b/src/main/browsers/constants.ts new file mode 100644 index 0000000..5c8ff7d --- /dev/null +++ b/src/main/browsers/constants.ts @@ -0,0 +1,38 @@ +import type { BrowserEngine } from '../../shared/types'; + +/** + * How a browser's engine is told from its names (launcher id, program, app or registry name), first match first: + * what the app can do with it depends on the engine. Names no pattern knows are `unknown` (open only). + */ +export const ENGINE_PATTERNS: readonly (readonly [RegExp, BrowserEngine])[] = [ + [/firefox|librewolf|waterfox|floorp|icecat|mullvad|tor[- ]?browser|seamonkey|palemoon|\bzen\b|zen[-_.]browser/i, 'gecko'], + [/safari|epiphany|gnome[- ]web|\borion\b/i, 'webkit'], + [/chrom(e|ium)|msedge|microsoft[- ]edge|brave|vivaldi|\bopera\b|^arc$|thorium|yandex|ungoogled/i, 'chromium'], +]; + +/** How long a scan for installed browsers is reused before the next one looks again. */ +export const SCAN_REUSE_MS = 60_000; + +/** The icon's longest side, in pixels: sharp at 32 px on a 2× screen. */ +export const ICON_SIZE = 64; + +/** An SVG icon larger than this is left out (they are usually a few kilobytes). */ +export const MAX_SVG_ICON_BYTES = 256 * 1024; + +/** How long asking a browser for its version may take before it is given up on. */ +export const VERSION_TIMEOUT_MS = 5000; + +/** A version number in a program's answer (`Mozilla Firefox 131.0.3`). */ +export const VERSION_NUMBER = /\d+(?:\.\d+)+/; + +/** The flag Chromium and Firefox answer with their version, without opening a window. */ +export const VERSION_FLAG = '--version'; + +/** Ids of browsers the user added, and of the ones found per system. */ +export const BROWSER_ID_PREFIX = { added: 'added:', desktop: 'desktop:', mac: 'mac:', windows: 'win:' } as const; + +/** + * Variables an AppImage's launcher sets for the app's own libraries: a browser started with them loads the wrong + * ones, so they are dropped when the app runs as an AppImage. + */ +export const APPIMAGE_ENV = { marker: 'APPIMAGE', dropped: ['LD_LIBRARY_PATH', 'LD_PRELOAD', 'GDK_PIXBUF_MODULE_FILE', 'GIO_EXTRA_MODULES', 'GSETTINGS_SCHEMA_DIR', 'GTK_PATH', 'QT_PLUGIN_PATH', 'PYTHONHOME', 'PYTHONPATH', 'PERLLIB'] } as const; diff --git a/src/main/browsers/engineOf.ts b/src/main/browsers/engineOf.ts new file mode 100644 index 0000000..7076696 --- /dev/null +++ b/src/main/browsers/engineOf.ts @@ -0,0 +1,10 @@ +import { basename } from 'node:path'; +import type { BrowserEngine } from '../../shared/types'; +import { ENGINE_PATTERNS } from './constants'; + +/** A browser's engine, from its names (launcher id, program path, app or registry name): the first pattern any of them matches. */ +export function engineOf(names: (string | null | undefined)[]): BrowserEngine { + const known = names.filter((name): name is string => !!name).map((name) => basename(name)); + for (const [pattern, engine] of ENGINE_PATTERNS) if (known.some((name) => pattern.test(name))) return engine; + return 'unknown'; +} diff --git a/src/main/browsers/findBrowsers/findBrowsers.ts b/src/main/browsers/findBrowsers/findBrowsers.ts new file mode 100644 index 0000000..6b1aede --- /dev/null +++ b/src/main/browsers/findBrowsers/findBrowsers.ts @@ -0,0 +1,16 @@ +import type { FoundBrowser } from '../types'; +import { findLinuxBrowsers } from './linux/findLinuxBrowsers'; +import { findMacBrowsers } from './mac/findMacBrowsers'; +import { findWindowsBrowsers } from './windows/findWindowsBrowsers'; + +/** How each system lists its installed browsers; other systems have none the app knows how to find. */ +const FINDERS: Partial Promise>> = { + linux: findLinuxBrowsers, + darwin: findMacBrowsers, + win32: findWindowsBrowsers, +}; + +/** The browsers installed on this computer. */ +export function findBrowsers(): Promise { + return FINDERS[process.platform]?.() ?? Promise.resolve([]); +} diff --git a/src/main/browsers/findBrowsers/index.ts b/src/main/browsers/findBrowsers/index.ts new file mode 100644 index 0000000..ae0e65c --- /dev/null +++ b/src/main/browsers/findBrowsers/index.ts @@ -0,0 +1 @@ +export { findBrowsers } from './findBrowsers'; diff --git a/src/main/browsers/findBrowsers/linux/commandOf.ts b/src/main/browsers/findBrowsers/linux/commandOf.ts new file mode 100644 index 0000000..dfb9e95 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/commandOf.ts @@ -0,0 +1,18 @@ +import type { BrowserCommand } from '../../types'; +import { FIELD_CODE, LITERAL_PERCENT, URL_FIELD_CODES } from './constants'; + +const URL_CODES = new Set(URL_FIELD_CODES); + +/** A launcher's arguments without field codes, and where the address goes: where its first `%u` (or `%U`, `%f`, `%F`) was, else last. */ +export function commandOf(args: string[]): BrowserCommand { + const command: string[] = []; + let urlAt = -1; + for (const arg of args) { + if (URL_CODES.has(arg)) { + if (urlAt < 0) urlAt = command.length; + } else if (!FIELD_CODE.test(arg)) { + command.push(arg.replaceAll(LITERAL_PERCENT, '%')); + } + } + return { command, urlAt: urlAt < 0 ? command.length : urlAt }; +} diff --git a/src/main/browsers/findBrowsers/linux/constants.ts b/src/main/browsers/findBrowsers/linux/constants.ts new file mode 100644 index 0000000..347d1b8 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/constants.ts @@ -0,0 +1,41 @@ +/** What the Desktop Entry spec says a browser's launcher looks like, and where the app looks for them. */ +export const DESKTOP_ENTRY = { + /** The group holding the entry's own keys (others are its actions). */ + group: '[Desktop Entry]', + extension: '.desktop', + /** The category browsers file themselves under. */ + browserCategory: 'WebBrowser', + /** Handling these schemes, with HTML files, marks a browser that names no category. */ + webSchemes: ['x-scheme-handler/http', 'x-scheme-handler/https'], + html: 'text/html', + /** Launchers for the app itself are left out. */ + ownName: 'console-editor', +} as const; + +/** Where launchers live besides the XDG data folders: what Flatpak and Snap export (usually on XDG_DATA_DIRS already). */ +export const PACKAGED_DATA_DIRS = ['/var/lib/flatpak/exports/share', '/var/lib/snapd/desktop'] as const; + +/** The user's own Flatpak exports, under the home folder. */ +export const USER_FLATPAK_DATA_DIR = '.local/share/flatpak/exports/share'; + +/** Labels added to a browser's name when two have the same one: where each came from. */ +export const PACKAGE_LABELS: readonly (readonly [RegExp, string])[] = [ + [/\/flatpak\//, 'Flatpak'], + [/\/snapd?\//, 'Snap'], +]; + +/** Exec field codes that stand for the address opened (the first one used is replaced, the rest dropped). */ +export const URL_FIELD_CODES = ['%u', '%U', '%f', '%F'] as const; + +/** Any other field code: dropped. `%%` is a literal percent sign. */ +export const FIELD_CODE = /^%[a-zA-Z]$/; +export const LITERAL_PERCENT = '%%'; + +/** Icon sizes to take, best first: sharp at 32 px on a 2× screen, then larger ones scaled down, then smaller ones. */ +export const ICON_SIZES = ['64x64', '128x128', '96x96', '256x256', '512x512', '48x48', '32x32'] as const; + +/** Icon themes to look in: the user's first, then the fallback theme every app installs into. */ +export const ICON_FOLDERS = { themes: 'icons/hicolor', userIcons: '.icons/hicolor', context: 'apps', scalable: 'scalable', pixmaps: '/usr/share/pixmaps' } as const; + +/** The image types an icon comes as, by extension. */ +export const ICON_EXTENSIONS = ['.png', '.svg'] as const; diff --git a/src/main/browsers/findBrowsers/linux/desktopDataDirs.ts b/src/main/browsers/findBrowsers/linux/desktopDataDirs.ts new file mode 100644 index 0000000..c4f7fa1 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/desktopDataDirs.ts @@ -0,0 +1,10 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { xdgDataDirs } from '../../../desktopEntry/xdgDataDirs'; +import { xdgDataHome } from '../../../desktopEntry/xdgDataHome'; +import { PACKAGED_DATA_DIRS, USER_FLATPAK_DATA_DIR } from './constants'; + +/** The data folders launchers and icons are in, most important first: the user's, the system's, then Flatpak's and Snap's. */ +export function desktopDataDirs(): string[] { + return [...new Set([xdgDataHome(), join(homedir(), USER_FLATPAK_DATA_DIR), ...xdgDataDirs(), ...PACKAGED_DATA_DIRS])]; +} diff --git a/src/main/browsers/findBrowsers/linux/desktopFiles.ts b/src/main/browsers/findBrowsers/linux/desktopFiles.ts new file mode 100644 index 0000000..91222e9 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/desktopFiles.ts @@ -0,0 +1,18 @@ +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { DESKTOP_ENTRY } from './constants'; + +/** How deep launchers are looked for below an applications folder (`kde4/konqueror.desktop` is one level down). */ +const MAX_DEPTH = 2; + +/** The launchers in an applications folder, as paths relative to it; none when it isn't there. */ +export async function desktopFiles(dir: string, prefix = '', depth = 0): Promise { + const entries = await readdir(join(dir, prefix), { withFileTypes: true }).catch(() => []); + const files: string[] = []; + for (const entry of entries) { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory() && depth < MAX_DEPTH) files.push(...(await desktopFiles(dir, path, depth + 1))); + else if (!entry.isDirectory() && entry.name.endsWith(DESKTOP_ENTRY.extension)) files.push(path); + } + return files.sort((a, b) => a.localeCompare(b)); +} diff --git a/src/main/browsers/findBrowsers/linux/findLinuxBrowsers.ts b/src/main/browsers/findBrowsers/linux/findLinuxBrowsers.ts new file mode 100644 index 0000000..9a79103 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/findLinuxBrowsers.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path'; +import type { FoundBrowser } from '../../types'; +import { desktopDataDirs } from './desktopDataDirs'; +import { desktopFiles } from './desktopFiles'; +import { labelDuplicates } from './labelDuplicates'; +import { readDesktopBrowser } from './readDesktopBrowser'; + +const APPLICATIONS = 'applications'; + +/** + * The browsers the desktop's launchers start. A launcher's id is its path below its applications folder (`/` as `-`); + * the first folder with an id wins, as the spec says (the user's own entry replaces the system's, browser or not). + */ +export async function findLinuxBrowsers(): Promise { + const dataDirs = desktopDataDirs(); + const seen = new Set(); + const found: { browser: FoundBrowser; path: string }[] = []; + for (const dir of dataDirs) { + const applications = join(dir, APPLICATIONS); + for (const file of await desktopFiles(applications)) { + const fileId = file.replaceAll('/', '-'); + if (seen.has(fileId)) continue; + seen.add(fileId); + const path = join(applications, file); + const browser = await readDesktopBrowser(path, fileId, dataDirs); + if (browser) found.push({ browser, path }); + } + } + return labelDuplicates(found); +} diff --git a/src/main/browsers/findBrowsers/linux/findLinuxIcon.ts b/src/main/browsers/findBrowsers/linux/findLinuxIcon.ts new file mode 100644 index 0000000..12a5911 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/findLinuxIcon.ts @@ -0,0 +1,27 @@ +import { access } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { extname, isAbsolute, join } from 'node:path'; +import { ICON_EXTENSIONS, ICON_FOLDERS, ICON_SIZES } from './constants'; + +/** + * A launcher's `Icon` as an image file: a path as given, else the name looked up in the fallback icon theme (sizes + * best first, the user's folders first), then its SVG, then the old pixmaps folder. Null when none is there. + */ +export async function findLinuxIcon(icon: string | undefined, dataDirs: string[]): Promise { + if (!icon) return null; + const exists = (path: string) => + access(path).then( + () => true, + () => false, + ); + if (isAbsolute(icon)) return (await exists(icon)) ? icon : null; + const name = (ICON_EXTENSIONS as readonly string[]).includes(extname(icon)) ? icon.slice(0, -extname(icon).length) : icon; + const themes = [join(homedir(), ICON_FOLDERS.userIcons), ...dataDirs.map((dir) => join(dir, ICON_FOLDERS.themes))]; + const candidates = [ + ...ICON_SIZES.flatMap((size) => themes.map((theme) => join(theme, size, ICON_FOLDERS.context, `${name}.png`))), + ...themes.map((theme) => join(theme, ICON_FOLDERS.scalable, ICON_FOLDERS.context, `${name}.svg`)), + ...ICON_EXTENSIONS.map((extension) => join(ICON_FOLDERS.pixmaps, `${name}${extension}`)), + ]; + const found = await Promise.all(candidates.map(exists)); + return candidates[found.indexOf(true)] ?? null; +} diff --git a/src/main/browsers/findBrowsers/linux/isBrowserEntry.ts b/src/main/browsers/findBrowsers/linux/isBrowserEntry.ts new file mode 100644 index 0000000..347203f --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/isBrowserEntry.ts @@ -0,0 +1,14 @@ +import { DESKTOP_ENTRY } from './constants'; + +const TRUE = 'true'; + +/** Whether a launcher's keys are a browser's to offer: an application shown in menus that files itself as a browser, or opens web links and HTML. */ +export function isBrowserEntry(keys: Record, fileId: string): boolean { + if (keys.Type !== 'Application' || !keys.Exec || !keys.Name) return false; + if (keys.NoDisplay === TRUE || keys.Hidden === TRUE || keys.Terminal === TRUE) return false; + if (fileId.includes(DESKTOP_ENTRY.ownName)) return false; + // Lists of the spec's kind: `a;b;`. + if ((keys.Categories ?? '').split(';').includes(DESKTOP_ENTRY.browserCategory)) return true; + const types = (keys.MimeType ?? '').split(';'); + return types.includes(DESKTOP_ENTRY.html) && DESKTOP_ENTRY.webSchemes.some((scheme) => types.includes(scheme)); +} diff --git a/src/main/browsers/findBrowsers/linux/labelDuplicates.ts b/src/main/browsers/findBrowsers/linux/labelDuplicates.ts new file mode 100644 index 0000000..4933e5a --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/labelDuplicates.ts @@ -0,0 +1,12 @@ +import type { FoundBrowser } from '../../types'; +import { PACKAGE_LABELS } from './constants'; + +/** The browsers, a name that two share given where each came from (`Firefox (Snap)`), so the menu tells them apart. */ +export function labelDuplicates(found: { browser: FoundBrowser; path: string }[]): FoundBrowser[] { + const count = new Map(); + for (const { browser } of found) count.set(browser.name, (count.get(browser.name) ?? 0) + 1); + return found.map(({ browser, path }) => { + const label = (count.get(browser.name) ?? 0) > 1 ? PACKAGE_LABELS.find(([pattern]) => pattern.test(path))?.[1] : undefined; + return label ? { ...browser, name: `${browser.name} (${label})` } : browser; + }); +} diff --git a/src/main/browsers/findBrowsers/linux/parseDesktopEntry.ts b/src/main/browsers/findBrowsers/linux/parseDesktopEntry.ts new file mode 100644 index 0000000..a76dd6f --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/parseDesktopEntry.ts @@ -0,0 +1,28 @@ +import { DESKTOP_ENTRY } from './constants'; + +/** The spec's general escapes; others are kept as written (Exec has quoting rules of its own on top). */ +const ESCAPES: Record = { s: ' ', n: '\n', t: '\t', r: '\r', '\\': '\\' }; +const ESCAPE = /\\([sntr\\])/g; + +/** A desktop entry's own keys (its `[Desktop Entry]` group), without localized ones (`Name[de]`), values unescaped of `\s \n \t \r \\`. */ +export function parseDesktopEntry(text: string): Record { + const keys: Record = {}; + let inGroup = false; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + if (line.startsWith('[')) { + inGroup = line === DESKTOP_ENTRY.group; + continue; + } + const eq = line.indexOf('='); + if (!inGroup || eq < 0) continue; + const key = line.slice(0, eq).trim(); + if (key.includes('[') || Object.hasOwn(keys, key)) continue; + keys[key] = line + .slice(eq + 1) + .trim() + .replace(ESCAPE, (_, c: string) => ESCAPES[c]); + } + return keys; +} diff --git a/src/main/browsers/findBrowsers/linux/programExists.ts b/src/main/browsers/findBrowsers/linux/programExists.ts new file mode 100644 index 0000000..ac269c6 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/programExists.ts @@ -0,0 +1,17 @@ +import { accessSync, constants } from 'node:fs'; +import { delimiter, isAbsolute, join } from 'node:path'; + +/** Whether `name` can be run: an absolute path to an executable file, or a command found on PATH. */ +export function programExists(name: string): boolean { + const runnable = (path: string) => { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } + }; + if (isAbsolute(name)) return runnable(name); + if (name.includes('/')) return false; + return (process.env.PATH ?? '').split(delimiter).some((dir) => dir && runnable(join(dir, name))); +} diff --git a/src/main/browsers/findBrowsers/linux/programOf.ts b/src/main/browsers/findBrowsers/linux/programOf.ts new file mode 100644 index 0000000..6583f5e --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/programOf.ts @@ -0,0 +1,11 @@ +/** An environment assignment before the program (`env A=1 firefox`). */ +const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/; +const ENV = 'env'; + +/** The program a launcher's command runs: past `env` and its variable assignments. */ +export function programOf(command: string[]): string | null { + let i = 0; + if (command[i] === ENV) i++; + while (i < command.length && ASSIGNMENT.test(command[i])) i++; + return command[i] ?? null; +} diff --git a/src/main/browsers/findBrowsers/linux/readDesktopBrowser.ts b/src/main/browsers/findBrowsers/linux/readDesktopBrowser.ts new file mode 100644 index 0000000..a9a13d7 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/readDesktopBrowser.ts @@ -0,0 +1,32 @@ +import { readFile } from 'node:fs/promises'; +import { BROWSER_ID_PREFIX } from '../../constants'; +import { engineOf } from '../../engineOf'; +import type { FoundBrowser } from '../../types'; +import { commandOf } from './commandOf'; +import { findLinuxIcon } from './findLinuxIcon'; +import { isBrowserEntry } from './isBrowserEntry'; +import { parseDesktopEntry } from './parseDesktopEntry'; +import { programExists } from './programExists'; +import { programOf } from './programOf'; +import { splitExec } from './splitExec'; + +/** The browser a launcher starts, when it is one whose program is there (its `TryExec`, else the one it runs); null otherwise. */ +export async function readDesktopBrowser(path: string, fileId: string, dataDirs: string[]): Promise { + const text = await readFile(path, 'utf8').catch(() => null); + if (text === null) return null; + const keys = parseDesktopEntry(text); + if (!isBrowserEntry(keys, fileId)) return null; + const command = commandOf(splitExec(keys.Exec)); + const program = programOf(command.command); + if (!program || !programExists(keys.TryExec || program)) return null; + return { + id: `${BROWSER_ID_PREFIX.desktop}${fileId}`, + name: keys.Name, + engine: engineOf([fileId, program, keys.Name]), + ...command, + iconFile: await findLinuxIcon(keys.Icon, dataDirs), + app: null, + program, + added: false, + }; +} diff --git a/src/main/browsers/findBrowsers/linux/splitExec.ts b/src/main/browsers/findBrowsers/linux/splitExec.ts new file mode 100644 index 0000000..7e7c331 --- /dev/null +++ b/src/main/browsers/findBrowsers/linux/splitExec.ts @@ -0,0 +1,31 @@ +/** Characters a backslash escapes inside a quoted Exec argument. */ +const QUOTED_ESCAPES = new Set(['"', '`', '$', '\\']); + +/** + * An Exec value's arguments, as the Desktop Entry spec quotes them: split on spaces outside double quotes, and inside + * them a backslash escapes `"`, `` ` ``, `$` and `\`. + */ +export function splitExec(exec: string): string[] { + const args: string[] = []; + let current = ''; + let quoted = false; + let started = false; + for (let i = 0; i < exec.length; i++) { + const c = exec[i]; + if (quoted && c === '\\' && QUOTED_ESCAPES.has(exec[i + 1])) { + current += exec[++i]; + } else if (c === '"') { + quoted = !quoted; + started = true; + } else if (!quoted && (c === ' ' || c === '\t')) { + if (started) args.push(current); + current = ''; + started = false; + } else { + current += c; + started = true; + } + } + if (started) args.push(current); + return args; +} diff --git a/src/main/browsers/findBrowsers/mac/constants.ts b/src/main/browsers/findBrowsers/mac/constants.ts new file mode 100644 index 0000000..772cdba --- /dev/null +++ b/src/main/browsers/findBrowsers/mac/constants.ts @@ -0,0 +1,8 @@ +/** Where apps are installed: for everyone, then for the user alone (under the home folder). */ +export const MAC_APP_DIRS = { system: '/Applications', user: 'Applications' } as const; + +export const APP_EXTENSION = '.app'; + +/** Opens an address in a given app: `open -a `. */ +export const OPEN_COMMAND = 'open'; +export const OPEN_APP_FLAG = '-a'; diff --git a/src/main/browsers/findBrowsers/mac/findMacBrowsers.ts b/src/main/browsers/findBrowsers/mac/findMacBrowsers.ts new file mode 100644 index 0000000..9b1b8cf --- /dev/null +++ b/src/main/browsers/findBrowsers/mac/findMacBrowsers.ts @@ -0,0 +1,24 @@ +import { readdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { BROWSER_ID_PREFIX } from '../../constants'; +import { engineOf } from '../../engineOf'; +import type { FoundBrowser } from '../../types'; +import { APP_EXTENSION, MAC_APP_DIRS, OPEN_APP_FLAG, OPEN_COMMAND } from './constants'; + +/** The browsers in the Applications folders (for everyone first): apps whose name is a browser's the app knows. */ +export async function findMacBrowsers(): Promise { + const found = new Map(); + for (const dir of [MAC_APP_DIRS.system, join(homedir(), MAC_APP_DIRS.user)]) { + for (const file of (await readdir(dir).catch((): string[] => [])).sort((a, b) => a.localeCompare(b))) { + if (!file.endsWith(APP_EXTENSION)) continue; + const name = file.slice(0, -APP_EXTENSION.length); + const engine = engineOf([name]); + if (engine === 'unknown' || found.has(name)) continue; + const app = join(dir, file); + const command = [OPEN_COMMAND, OPEN_APP_FLAG, app]; + found.set(name, { id: `${BROWSER_ID_PREFIX.mac}${name}`, name, engine, command, urlAt: command.length, iconFile: null, app, program: null, added: false }); + } + } + return [...found.values()]; +} diff --git a/src/main/browsers/findBrowsers/windows/constants.ts b/src/main/browsers/findBrowsers/windows/constants.ts new file mode 100644 index 0000000..78694bf --- /dev/null +++ b/src/main/browsers/findBrowsers/windows/constants.ts @@ -0,0 +1,18 @@ +/** Where Windows lists the browsers a user can pick as their default, for everyone, the user alone, and 32-bit ones. */ +export const START_MENU_INTERNET = [ + 'HKLM\\SOFTWARE\\Clients\\StartMenuInternet', + 'HKCU\\SOFTWARE\\Clients\\StartMenuInternet', + 'HKLM\\SOFTWARE\\WOW6432Node\\Clients\\StartMenuInternet', +] as const; + +/** `reg query /s`: the key and every key below it. */ +export const REG = { program: 'reg', query: 'query', recursive: '/s', timeoutMs: 10_000 } as const; + +/** The key under a browser's that holds the command starting it. */ +export const OPEN_COMMAND_KEY = '\\shell\\open\\command'; + +/** How `reg query` names a key's unnamed value. */ +export const DEFAULT_VALUE = '(default)'; + +/** A value's line in `reg query`'s output: four spaces before its name, its type and its data. */ +export const REG_VALUE_LINE = /^ {4}(.+?) {4}(REG_[A-Z_]+)(?: {4}(.*))?$/; diff --git a/src/main/browsers/findBrowsers/windows/findWindowsBrowsers.ts b/src/main/browsers/findBrowsers/windows/findWindowsBrowsers.ts new file mode 100644 index 0000000..eaae41d --- /dev/null +++ b/src/main/browsers/findBrowsers/windows/findWindowsBrowsers.ts @@ -0,0 +1,35 @@ +import { BROWSER_ID_PREFIX } from '../../constants'; +import { engineOf } from '../../engineOf'; +import { runProgram } from '../../runProgram'; +import type { FoundBrowser } from '../../types'; +import { DEFAULT_VALUE, OPEN_COMMAND_KEY, REG, START_MENU_INTERNET } from './constants'; +import { parseRegQuery } from './parseRegQuery'; +import { programOfCommand } from './programOfCommand'; + +/** The browsers Windows offers as the default one (StartMenuInternet), each once: the first key naming a program wins. */ +export async function findWindowsBrowsers(): Promise { + const found = new Map(); + for (const root of START_MENU_INTERNET) { + const keys = parseRegQuery(await runProgram(REG.program, [REG.query, root, REG.recursive], REG.timeoutMs).catch(() => '')); + for (const [path, key] of keys) { + if (!path.endsWith(OPEN_COMMAND_KEY)) continue; + const program = programOfCommand(key.values.get(DEFAULT_VALUE) ?? ''); + if (!program || found.has(program.toLowerCase())) continue; + const browserKey = keys.get(path.slice(0, -OPEN_COMMAND_KEY.length)); + const keyName = (browserKey?.path ?? path).split('\\').pop() ?? program; + const name = browserKey?.values.get(DEFAULT_VALUE) || keyName; + found.set(program.toLowerCase(), { + id: `${BROWSER_ID_PREFIX.windows}${keyName}`, + name, + engine: engineOf([program, keyName, name]), + command: [program], + urlAt: 1, + iconFile: null, + app: program, + program, + added: false, + }); + } + } + return [...found.values()]; +} diff --git a/src/main/browsers/findBrowsers/windows/parseRegQuery.ts b/src/main/browsers/findBrowsers/windows/parseRegQuery.ts new file mode 100644 index 0000000..4d23b1c --- /dev/null +++ b/src/main/browsers/findBrowsers/windows/parseRegQuery.ts @@ -0,0 +1,19 @@ +import { REG_VALUE_LINE } from './constants'; +import type { RegKey } from './types'; + +/** `reg query /s` output as its keys, by path in lower case (the registry ignores case), value names in lower case too. */ +export function parseRegQuery(output: string): Map { + const keys = new Map(); + let current: RegKey | undefined; + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) continue; + const value = REG_VALUE_LINE.exec(line); + if (value && current) { + current.values.set(value[1].toLowerCase(), value[3] ?? ''); + } else if (!line.startsWith(' ')) { + current = { path: line.trim(), values: new Map() }; + keys.set(current.path.toLowerCase(), current); + } + } + return keys; +} diff --git a/src/main/browsers/findBrowsers/windows/programOfCommand.ts b/src/main/browsers/findBrowsers/windows/programOfCommand.ts new file mode 100644 index 0000000..a1ce8ec --- /dev/null +++ b/src/main/browsers/findBrowsers/windows/programOfCommand.ts @@ -0,0 +1,9 @@ +/** The program a registry command starts: its first argument, quoted (`"C:\…\chrome.exe" --flag`) or not. */ +export function programOfCommand(command: string): string | null { + const trimmed = command.trim(); + if (trimmed.startsWith('"')) { + const end = trimmed.indexOf('"', 1); + return end > 1 ? trimmed.slice(1, end) : null; + } + return trimmed.split(/\s+/)[0] || null; +} diff --git a/src/main/browsers/findBrowsers/windows/types.ts b/src/main/browsers/findBrowsers/windows/types.ts new file mode 100644 index 0000000..15d2bee --- /dev/null +++ b/src/main/browsers/findBrowsers/windows/types.ts @@ -0,0 +1,5 @@ +/** A registry key as `reg query` printed it: its path, and its values by name in lower case. */ +export interface RegKey { + path: string; + values: Map; +} diff --git a/src/main/browsers/index.ts b/src/main/browsers/index.ts new file mode 100644 index 0000000..3d3de50 --- /dev/null +++ b/src/main/browsers/index.ts @@ -0,0 +1,2 @@ +export { BrowserRegistry } from './BrowserRegistry'; +export type { FoundBrowser } from './types'; diff --git a/src/main/browsers/launchEnv.ts b/src/main/browsers/launchEnv.ts new file mode 100644 index 0000000..f077479 --- /dev/null +++ b/src/main/browsers/launchEnv.ts @@ -0,0 +1,9 @@ +import { APPIMAGE_ENV } from './constants'; + +/** The environment a browser starts with: the app's own, less what an AppImage set for the app's libraries. */ +export function launchEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + if (!env[APPIMAGE_ENV.marker]) return env; + const clean = { ...env }; + for (const name of APPIMAGE_ENV.dropped) delete clean[name]; + return clean; +} diff --git a/src/main/browsers/readVersion/constants.ts b/src/main/browsers/readVersion/constants.ts new file mode 100644 index 0000000..28d061e --- /dev/null +++ b/src/main/browsers/readVersion/constants.ts @@ -0,0 +1,14 @@ +/** Reads a key of an app's Info.plist, XML or binary: `plutil -extract raw -o - `. */ +export const PLUTIL = { program: 'plutil', extract: '-extract', raw: 'raw', stdout: ['-o', '-'], versionKey: 'CFBundleShortVersionString', plist: 'Contents/Info.plist' } as const; + +/** Reads a program's file version with PowerShell: the script around the path, given as a single-quoted literal. */ +export const POWERSHELL = { + program: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command'], + before: "(Get-Item -LiteralPath '", + after: "').VersionInfo.ProductVersion", + quote: "'", +} as const; + +/** A Flatpak app is started through `flatpak run`; the markers around forwarded files aren't for asking its version. */ +export const FLATPAK = { program: 'flatpak', markers: ['@@u', '@@'] as readonly string[] } as const; diff --git a/src/main/browsers/readVersion/index.ts b/src/main/browsers/readVersion/index.ts new file mode 100644 index 0000000..fff5032 --- /dev/null +++ b/src/main/browsers/readVersion/index.ts @@ -0,0 +1 @@ +export { readVersion } from './readVersion'; diff --git a/src/main/browsers/readVersion/readVersion.ts b/src/main/browsers/readVersion/readVersion.ts new file mode 100644 index 0000000..20f9876 --- /dev/null +++ b/src/main/browsers/readVersion/readVersion.ts @@ -0,0 +1,24 @@ +import { basename, join } from 'node:path'; +import { VERSION_FLAG, VERSION_NUMBER, VERSION_TIMEOUT_MS } from '../constants'; +import { runProgram } from '../runProgram'; +import type { FoundBrowser } from '../types'; +import { FLATPAK, PLUTIL, POWERSHELL } from './constants'; + +/** + * A browser's version, the system's way: its app's Info.plist (macOS), its program's file version (Windows), else + * what the program answers to `--version` (Chromium, Firefox and GNOME Web do, without opening a window). Null when + * none is found in time. + */ +export async function readVersion(browser: FoundBrowser): Promise { + const asked = async (): Promise => { + if (process.platform === 'darwin' && browser.app) return runProgram(PLUTIL.program, [PLUTIL.extract, PLUTIL.versionKey, PLUTIL.raw, ...PLUTIL.stdout, join(browser.app, PLUTIL.plist)], VERSION_TIMEOUT_MS); + if (process.platform === 'win32' && browser.program) return runProgram(POWERSHELL.program, [...POWERSHELL.args, `${POWERSHELL.before}${browser.program.replaceAll(POWERSHELL.quote, POWERSHELL.quote.repeat(2))}${POWERSHELL.after}`], VERSION_TIMEOUT_MS); + if (browser.engine === 'unknown' || !browser.program) return ''; + // The program alone (a launcher's own flags could open a window); a Flatpak one through `flatpak run … `. + const [program, ...args] = + basename(browser.program) === FLATPAK.program ? [...browser.command.slice(0, browser.urlAt).filter((arg) => !FLATPAK.markers.includes(arg)), VERSION_FLAG] : [browser.program, VERSION_FLAG]; + return runProgram(program, args, VERSION_TIMEOUT_MS); + }; + const answer = await asked().catch(() => ''); + return VERSION_NUMBER.exec(answer)?.[0] ?? null; +} diff --git a/src/main/browsers/runProgram.ts b/src/main/browsers/runProgram.ts new file mode 100644 index 0000000..eab1bef --- /dev/null +++ b/src/main/browsers/runProgram.ts @@ -0,0 +1,8 @@ +import { execFile } from 'node:child_process'; + +/** What a program prints, once it exits; rejects when it fails or takes longer than `timeoutMs`. */ +export function runProgram(file: string, args: string[], timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + execFile(file, args, { timeout: timeoutMs, windowsHide: true, encoding: 'utf8' }, (err, stdout) => (err ? reject(err) : resolve(stdout))); + }); +} diff --git a/src/main/browsers/startBrowser.ts b/src/main/browsers/startBrowser.ts new file mode 100644 index 0000000..1971ec4 --- /dev/null +++ b/src/main/browsers/startBrowser.ts @@ -0,0 +1,16 @@ +import { spawn } from 'node:child_process'; +import { launchEnv } from './launchEnv'; +import type { BrowserCommand } from './types'; + +/** Starts a browser's command with `args` where the address goes, on its own (it outlives the app); resolves once it has started. */ +export function startBrowser({ command, urlAt }: BrowserCommand, args: string[]): Promise { + const [program, ...rest] = [...command.slice(0, urlAt), ...args, ...command.slice(urlAt)]; + return new Promise((resolve, reject) => { + const child = spawn(program, rest, { detached: true, stdio: 'ignore', env: launchEnv(), windowsHide: false }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); +} diff --git a/src/main/browsers/types.ts b/src/main/browsers/types.ts new file mode 100644 index 0000000..8ffb6a9 --- /dev/null +++ b/src/main/browsers/types.ts @@ -0,0 +1,30 @@ +import type { AppEvent, BrowserEngine } from '../../shared/types'; +import type { BrowserStore } from '../store/BrowserStore'; + +/** How a browser is started with an address: its program and arguments, and where among them the address goes. */ +export interface BrowserCommand { + command: string[]; + urlAt: number; +} + +/** A browser found on this computer, or one the user added, with what the app needs to start it. */ +export interface FoundBrowser extends BrowserCommand { + id: string; + name: string; + engine: BrowserEngine; + /** An image file for its icon (Linux: from its icon theme); null when the system gives one for `app`, or has none. */ + iconFile: string | null; + /** The app bundle (macOS) or program (Windows) whose icon and version the system gives; null on Linux. */ + app: string | null; + /** The program itself, when known: asked for its version on Linux. */ + program: string | null; + added: boolean; +} + +export interface BrowserRegistryDeps { + prefs: BrowserStore; + /** Pushes an event to the app's windows. */ + send(event: AppEvent): void; + /** Looks for the installed browsers (the system's own way by default; tests hand their own). */ + find?: () => Promise; +} diff --git a/src/main/ipc/registerBrowserIpc.ts b/src/main/ipc/registerBrowserIpc.ts new file mode 100644 index 0000000..b44a669 --- /dev/null +++ b/src/main/ipc/registerBrowserIpc.ts @@ -0,0 +1,38 @@ +import { dialog, type BrowserWindow, type OpenDialogOptions } from 'electron'; +import { IPC_CHANNEL } from '../../shared/ipcChannels'; +import type { BrowserRegistry } from '../browsers'; +import { assertString } from './assertString'; +import type { IpcHandle } from './types'; + +/** Where the dialog adding a browser opens, and which files it offers, per system. */ +const ADD_BROWSER_DIALOG: Partial>> = { + darwin: { defaultPath: '/Applications', filters: [{ name: 'Applications', extensions: ['app'] }] }, + win32: { defaultPath: 'C:\\Program Files', filters: [{ name: 'Programs', extensions: ['exe'] }] }, + linux: { defaultPath: '/usr/bin' }, +}; + +/** + * The other browsers' channels. Listing and opening one serve the toolbar in both windows (`handlePage`); adding, + * removing and hiding one are Settings', in the editor. + */ +export function registerBrowserIpc(handle: IpcHandle, handlePage: IpcHandle, { win, browsers }: { win: BrowserWindow; browsers: BrowserRegistry }): void { + handlePage(IPC_CHANNEL.listBrowsers, () => browsers.list()); + handlePage(IPC_CHANNEL.openInBrowser, (id: unknown, url: unknown) => { + assertString(id, 'id'); + assertString(url, 'url'); + return browsers.open(id, url); + }); + handle(IPC_CHANNEL.addBrowser, async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(win, { title: 'Add a browser', properties: ['openFile'], ...ADD_BROWSER_DIALOG[process.platform] }); + return canceled || !filePaths[0] ? null : browsers.add(filePaths[0]); + }); + handle(IPC_CHANNEL.removeBrowser, (id: unknown) => { + assertString(id, 'id'); + return browsers.remove(id); + }); + handle(IPC_CHANNEL.setBrowserHidden, (id: unknown, hidden: unknown) => { + assertString(id, 'id'); + if (typeof hidden !== 'boolean') throw new Error('Invalid hidden'); + return browsers.setHidden(id, hidden); + }); +} diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index c47fb8b..84fe07a 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -5,6 +5,7 @@ import { HTTP_URL } from '../constants'; import { assertString } from './assertString'; import { registerActionIpc } from './registerActionIpc'; import { registerActionsWindowIpc } from './registerActionsWindowIpc'; +import { registerBrowserIpc } from './registerBrowserIpc'; import { registerConsoleIpc } from './registerConsoleIpc'; import { registerHarIpc } from './registerHarIpc'; import { registerInspectorIpc } from './registerInspectorIpc'; @@ -16,7 +17,7 @@ import { registerSettingsIpc } from './registerSettingsIpc'; import { registerSourceMapIpc } from './registerSourceMapIpc'; import type { IpcDeps } from './types'; -export function registerIpc({ win, page, store, rules, settings, session, actions, sourceMaps, actionsWindow, workspaces, updates, send, onSessionFlushed }: IpcDeps): void { +export function registerIpc({ win, page, store, rules, settings, session, actions, sourceMaps, actionsWindow, workspaces, browsers, updates, send, onSessionFlushed }: IpcDeps): void { // Only the editor UI may call these (the website view has no preload, but be strict anyway). const fromEditor = (event: IpcMainInvokeEvent | IpcMainEvent) => event.sender.id === win.webContents.id; @@ -80,6 +81,7 @@ export function registerIpc({ win, page, store, rules, settings, session, action registerNetworkIpc(handle, page.network); registerHarIpc(handle, { win, page, store }); registerOverridesFileIpc(handle, { win, page, store, rules }); + registerBrowserIpc(handle, handlePage, { win, browsers }); handle(IPC_CHANNEL.getSession, () => session.get()); handle(IPC_CHANNEL.saveSessionTabs, (workspaceId: unknown, tabs: unknown, activeTabId: unknown) => session.setTabs(workspaceId, tabs, activeTabId)); diff --git a/src/main/ipc/types.ts b/src/main/ipc/types.ts index 6a677f7..d3b25a6 100644 --- a/src/main/ipc/types.ts +++ b/src/main/ipc/types.ts @@ -1,6 +1,7 @@ import type { BrowserWindow } from 'electron'; import type { AppEvent } from '../../shared/types'; import type { ActionsWindow } from '../ActionsWindow'; +import type { BrowserRegistry } from '../browsers'; import type { PageController } from '../PageController'; import type { ActionStore } from '../store/ActionStore'; import type { OverrideStore } from '../store/OverrideStore'; @@ -26,6 +27,8 @@ export interface IpcDeps { /** Where the Actions panel is; its own window's UI may use the action channels too. */ actionsWindow: ActionsWindow; workspaces: WorkspaceController; + /** The other browsers on this computer. */ + browsers: BrowserRegistry; updates: UpdateService; /** Pushes an event to the editor's UI. */ send(event: AppEvent): void; diff --git a/src/main/launch/constants.ts b/src/main/launch/constants.ts index e03b305..e68e303 100644 --- a/src/main/launch/constants.ts +++ b/src/main/launch/constants.ts @@ -9,6 +9,8 @@ export const USER_DATA = { pageWindow: 'page-window.json', /** Whether the Actions panel had a window of its own, where, and whether it stayed on top. */ actionsWindow: 'actions-window.json', + /** The browsers the user added, and the ones they turned off. */ + browsers: 'browsers.json', /** The last version run. */ updateRecord: 'update.json', /** Updates downloaded from a local update server (tests). */ diff --git a/src/main/launch/createWindow.ts b/src/main/launch/createWindow.ts index 96bfaea..5cdb2b5 100644 --- a/src/main/launch/createWindow.ts +++ b/src/main/launch/createWindow.ts @@ -5,6 +5,7 @@ import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { AppEvent } from '../../shared/types'; import { REPO_URL } from '../appInfo'; import { ActionsWindow } from '../ActionsWindow'; +import { BrowserRegistry } from '../browsers'; import { installMenu } from '../installMenu'; import { watchOverrideFiles } from '../overrideFiles'; import { registerIpc } from '../ipc'; @@ -33,14 +34,15 @@ export async function createWindow(updateFeed: string | undefined): Promise { if (!win.isDestroyed()) win.webContents.send(IPC_CHANNEL.onEvent, encodeEvent(event)); - // The Actions panel's own window, if it has one, shows what its panel needs. + // The Actions panel's own window, if it has one, shows what its panel needs; the website's, what its toolbar does. actionsWindow.forward(event); + page.window.forward(event); }; const actionsWindow = new ActionsWindow({ editor: win, store: actionsWindowStore, announce: (state) => send({ type: 'actions-window', state }) }); @@ -49,6 +51,7 @@ export async function createWindow(updateFeed: string | undefined): Promise send({ type: 'error', message: rulesProblem })); + const browsers = new BrowserRegistry({ prefs: browserPrefs, send }); const page = new PageController(win, { store, rules, settings, send, windowStore: pageWindow, breakpoints: () => activeBreakpoints(session) }); launchState.running = { win, page }; // Before the engine attaches: it serves the active workspace's overrides and rules from the start. @@ -71,7 +74,7 @@ export async function createWindow(updateFeed: string | undefined): Promise closing.flushed(ok) }); + registerIpc({ win, page, store, rules, settings, session, actions, sourceMaps, actionsWindow, workspaces, browsers, updates, send, onSessionFlushed: (ok) => closing.flushed(ok) }); lockEditorNavigation(win); diff --git a/src/main/launch/openStores.ts b/src/main/launch/openStores.ts index 226a139..0881b2f 100644 --- a/src/main/launch/openStores.ts +++ b/src/main/launch/openStores.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { ActionStore } from '../store/ActionStore'; +import { BrowserStore } from '../store/BrowserStore'; import { OverrideStore } from '../store/OverrideStore'; import { WindowStore } from '../store/WindowStore'; import { RuleStore } from '../store/RuleStore'; @@ -22,6 +23,7 @@ export async function openStores(userData: string): Promise { const actionsWindow = new WindowStore(join(userData, USER_DATA.actionsWindow)); const actions = new ActionStore(join(userData, USER_DATA.workspace)); const sourceMaps = new SourceMapFileStore(join(userData, USER_DATA.workspace)); - await Promise.all([store.load(), rules.load(), settings.load(), session.load(), pageWindow.load(), actionsWindow.load(), actions.load(), sourceMaps.load()]); - return { store, rules, settings, session, pageWindow, actionsWindow, actions, sourceMaps, hadData }; + const browsers = new BrowserStore(join(userData, USER_DATA.browsers)); + await Promise.all([store.load(), rules.load(), settings.load(), session.load(), pageWindow.load(), actionsWindow.load(), actions.load(), sourceMaps.load(), browsers.load()]); + return { store, rules, settings, session, pageWindow, actionsWindow, actions, sourceMaps, browsers, hadData }; } diff --git a/src/main/launch/types.ts b/src/main/launch/types.ts index 84aa177..d8c9a2c 100644 --- a/src/main/launch/types.ts +++ b/src/main/launch/types.ts @@ -1,5 +1,6 @@ import type { AppEvent } from '../../shared/types'; import type { ActionStore } from '../store/ActionStore'; +import type { BrowserStore } from '../store/BrowserStore'; import type { OverrideStore } from '../store/OverrideStore'; import type { WindowStore } from '../store/WindowStore'; import type { RuleStore } from '../store/RuleStore'; @@ -18,6 +19,7 @@ export interface AppStores { actionsWindow: WindowStore; actions: ActionStore; sourceMaps: SourceMapFileStore; + browsers: BrowserStore; /** The folder held data before the stores looked (an earlier version ran). */ hadData: boolean; } diff --git a/src/main/store/BrowserStore/BrowserStore.ts b/src/main/store/BrowserStore/BrowserStore.ts new file mode 100644 index 0000000..b91eca0 --- /dev/null +++ b/src/main/store/BrowserStore/BrowserStore.ts @@ -0,0 +1,50 @@ +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { WriteQueue } from '../WriteQueue'; +import { writeAtomic } from '../writeAtomic'; +import { BROWSERS_FILE_VERSION, MAX_ADDED_BROWSERS, MAX_HIDDEN_BROWSERS } from './constants'; +import { sanitizeBrowserPrefs } from './sanitizeBrowserPrefs'; +import type { AddedBrowser, BrowserPrefs } from './types'; + +/** The browsers the user added, and the ones they turned off (`browsers.json`), for every workspace. */ +export class BrowserStore { + private prefs: BrowserPrefs = { added: [], hidden: [] }; + private readonly writes = new WriteQueue(); + + constructor(private readonly path: string) {} + + async load(): Promise { + try { + this.prefs = sanitizeBrowserPrefs(JSON.parse(await readFile(this.path, 'utf8'))); + } catch { + this.prefs = { added: [], hidden: [] }; + } + } + + get(): BrowserPrefs { + return this.prefs; + } + + add(browser: AddedBrowser): Promise { + if (this.prefs.added.length >= MAX_ADDED_BROWSERS) return Promise.reject(new Error(`At most ${MAX_ADDED_BROWSERS} browsers can be added`)); + return this.save({ ...this.prefs, added: [...this.prefs.added, browser] }); + } + + remove(id: string): Promise { + return this.save({ added: this.prefs.added.filter((b) => b.id !== id), hidden: this.prefs.hidden.filter((h) => h !== id) }); + } + + setHidden(id: string, hidden: boolean): Promise { + const others = this.prefs.hidden.filter((h) => h !== id); + return this.save({ ...this.prefs, hidden: hidden ? [...others, id].slice(-MAX_HIDDEN_BROWSERS) : others }); + } + + /** Takes effect at once; written one change at a time, and a failed write rejects (the user asked for the change). */ + private save(next: BrowserPrefs): Promise { + this.prefs = next; + return this.writes.run(async () => { + await mkdir(dirname(this.path), { recursive: true }); + await writeAtomic(this.path, `${JSON.stringify({ version: BROWSERS_FILE_VERSION, ...next }, null, 2)}\n`); + }); + } +} diff --git a/src/main/store/BrowserStore/constants.ts b/src/main/store/BrowserStore/constants.ts new file mode 100644 index 0000000..8cd71d7 --- /dev/null +++ b/src/main/store/BrowserStore/constants.ts @@ -0,0 +1,12 @@ +/** The browsers file's version: a newer one is read as empty rather than misread. */ +export const BROWSERS_FILE_VERSION = 1; + +/** At most this many added browsers, and hidden ids, are kept. */ +export const MAX_ADDED_BROWSERS = 20; +export const MAX_HIDDEN_BROWSERS = 200; + +/** The longest name, path and id kept. */ +export const MAX_BROWSER_TEXT = 1024; + +/** An added browser's id. */ +export const ADDED_BROWSER_ID = /^added:[0-9a-f]{8}$/; diff --git a/src/main/store/BrowserStore/index.ts b/src/main/store/BrowserStore/index.ts new file mode 100644 index 0000000..da05a90 --- /dev/null +++ b/src/main/store/BrowserStore/index.ts @@ -0,0 +1,2 @@ +export { BrowserStore } from './BrowserStore'; +export type { AddedBrowser, BrowserPrefs } from './types'; diff --git a/src/main/store/BrowserStore/sanitizeBrowserPrefs.ts b/src/main/store/BrowserStore/sanitizeBrowserPrefs.ts new file mode 100644 index 0000000..d76b804 --- /dev/null +++ b/src/main/store/BrowserStore/sanitizeBrowserPrefs.ts @@ -0,0 +1,19 @@ +import { BROWSER_ENGINES, type BrowserEngine } from '../../../shared/types'; +import { isRecord } from '../isRecord'; +import { ADDED_BROWSER_ID, BROWSERS_FILE_VERSION, MAX_ADDED_BROWSERS, MAX_BROWSER_TEXT, MAX_HIDDEN_BROWSERS } from './constants'; +import type { AddedBrowser, BrowserPrefs } from './types'; + +/** The well-formed parts of a browsers file: a file of another version, or anything malformed, counts as none. */ +export function sanitizeBrowserPrefs(input: unknown): BrowserPrefs { + if (!isRecord(input) || input.version !== BROWSERS_FILE_VERSION) return { added: [], hidden: [] }; + const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= MAX_BROWSER_TEXT; + const added = (Array.isArray(input.added) ? input.added : []) + .filter( + (b): b is AddedBrowser => + isRecord(b) && text(b.id) && ADDED_BROWSER_ID.test(b.id) && text(b.name) && text(b.path) && BROWSER_ENGINES.includes(b.engine as BrowserEngine), + ) + .slice(0, MAX_ADDED_BROWSERS) + .map(({ id, name, path, engine }) => ({ id, name, path, engine })); + const hidden = [...new Set((Array.isArray(input.hidden) ? input.hidden : []).filter(text))].slice(0, MAX_HIDDEN_BROWSERS); + return { added, hidden }; +} diff --git a/src/main/store/BrowserStore/types.ts b/src/main/store/BrowserStore/types.ts new file mode 100644 index 0000000..5dc4831 --- /dev/null +++ b/src/main/store/BrowserStore/types.ts @@ -0,0 +1,17 @@ +import type { BrowserEngine } from '../../../shared/types'; + +/** A browser the user added by picking its program (or app, on macOS). */ +export interface AddedBrowser { + /** `added:` and 8 hex chars. */ + id: string; + name: string; + path: string; + engine: BrowserEngine; +} + +/** What the user changed about the browsers offered: the ones they added, and the ones they turned off. */ +export interface BrowserPrefs { + added: AddedBrowser[]; + /** Ids of browsers not offered beside the address bar. */ + hidden: string[]; +} diff --git a/src/main/windows/forwardEvent.ts b/src/main/windows/forwardEvent.ts new file mode 100644 index 0000000..c26ef12 --- /dev/null +++ b/src/main/windows/forwardEvent.ts @@ -0,0 +1,8 @@ +import type { BrowserWindow } from 'electron'; +import { IPC_CHANNEL } from '../../shared/ipcChannels'; +import type { AppEvent } from '../../shared/types'; + +/** Hands an app window's UI an event it listens for (one of `heard`), while the window is open; the rest aren't its business. */ +export function forwardEvent(win: BrowserWindow | undefined, heard: ReadonlySet, event: AppEvent): void { + if (win && !win.isDestroyed() && heard.has(event.type)) win.webContents.send(IPC_CHANNEL.onEvent, event); +} diff --git a/src/main/windows/index.ts b/src/main/windows/index.ts index 340cd19..0ef8435 100644 --- a/src/main/windows/index.ts +++ b/src/main/windows/index.ts @@ -1,5 +1,6 @@ export { createAppWindow } from './createAppWindow'; export { DockOnClose } from './DockOnClose'; +export { forwardEvent } from './forwardEvent'; export { placeWindow } from './placeWindow'; export { setUpWindow } from './setUpWindow'; export { syncMenuCheck } from './syncMenuCheck'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 3396548..d23ccd0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -85,6 +85,12 @@ const api: ConsoleEditorApi = { listHeldRequests: () => ipcRenderer.invoke(IPC_CHANNEL.listHeldRequests), resumeHeldRequest: (id, action) => ipcRenderer.invoke(IPC_CHANNEL.resumeHeldRequest, id, action), + listBrowsers: () => ipcRenderer.invoke(IPC_CHANNEL.listBrowsers), + openInBrowser: (id, url) => ipcRenderer.invoke(IPC_CHANNEL.openInBrowser, id, url), + addBrowser: () => ipcRenderer.invoke(IPC_CHANNEL.addBrowser), + removeBrowser: (id) => ipcRenderer.invoke(IPC_CHANNEL.removeBrowser, id), + setBrowserHidden: (id, hidden) => ipcRenderer.invoke(IPC_CHANNEL.setBrowserHidden, id, hidden), + getActionsWindow: () => ipcRenderer.invoke(IPC_CHANNEL.getActionsWindow), detachActions: () => ipcRenderer.invoke(IPC_CHANNEL.detachActions), attachActions: () => ipcRenderer.invoke(IPC_CHANNEL.attachActions), diff --git a/src/renderer/src/app/model/bridge/appEventHandlers.ts b/src/renderer/src/app/model/bridge/appEventHandlers.ts index 3f16ba5..55d1d74 100644 --- a/src/renderer/src/app/model/bridge/appEventHandlers.ts +++ b/src/renderer/src/app/model/bridge/appEventHandlers.ts @@ -1,4 +1,5 @@ import { useActionStore } from '@/entities/action'; +import { useBrowserStore } from '@/entities/browser'; import { useOverrideStore } from '@/entities/override'; import { useConsoleStore } from '@/entities/console-log'; import { useFrameStore } from '@/entities/frame'; @@ -68,6 +69,7 @@ export const APP_EVENT_HANDLERS: AppEventHandlers = { 'held-requests': (event) => receiveHeld(event.held), 'actions-window': (event) => useActionStore.getState().setWindow(event.state), 'settings-changed': (event) => useSettingsStore.getState().setSettings(event.settings), + 'browsers-changed': (event) => useBrowserStore.getState().setAll(event.browsers), command: (event) => runCommand(event.command), 'flush-session': answerFlushSession, update: (event) => handleUpdateState(event.state), diff --git a/src/renderer/src/app/model/bridge/startBridge.ts b/src/renderer/src/app/model/bridge/startBridge.ts index a6c0377..2cf29d2 100644 --- a/src/renderer/src/app/model/bridge/startBridge.ts +++ b/src/renderer/src/app/model/bridge/startBridge.ts @@ -8,6 +8,7 @@ import { usePageStackStore } from '@/entities/page-stack'; import { useRuleStore } from '@/entities/rule'; import { useSettingsStore } from '@/entities/settings'; import { useWorkspaceStore } from '@/entities/workspace'; +import { loadBrowsers } from '@/features/browser/open-in-browser'; import { receiveEntries } from '@/features/filter-console'; import { followUp } from '@/features/inspect/pick'; import { receiveHeld } from '@/features/network/held'; @@ -54,6 +55,9 @@ export async function startBridge(commands: PageCommands, session: PageSession): api.listHeldRequests().then(receiveHeld), ]); + // Not waited for: looking for the installed browsers is the system's pace, and only the toolbar's menu needs them. + void loadBrowsers(); + // Unsaved edits are kept as drafts rather than guarded: closing never asks to discard them. let stopSync: (() => void) | undefined; let stopped = false; diff --git a/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts b/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts index 2243fcb..05ae109 100644 --- a/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts +++ b/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts @@ -1,4 +1,5 @@ import type { MenuCommand } from '@common/types'; +import { useBrowserStore } from '@/entities/browser'; import { usePageStore } from '@/entities/page'; import { focusAddressBar } from '@/pages/page-window'; import type { PageWindowEventHandlers } from './types'; @@ -10,4 +11,5 @@ const PAGE_WINDOW_COMMANDS: Partial void>> = { 'focus- export const PAGE_WINDOW_EVENT_HANDLERS: PageWindowEventHandlers = { 'page-state': (event) => usePageStore.getState().setPage(event.state), command: (event) => PAGE_WINDOW_COMMANDS[event.command]?.(), + 'browsers-changed': (event) => useBrowserStore.getState().setAll(event.browsers), }; diff --git a/src/renderer/src/app/model/page-window-bridge/startPageWindowBridge.ts b/src/renderer/src/app/model/page-window-bridge/startPageWindowBridge.ts index c1d4a18..0161397 100644 --- a/src/renderer/src/app/model/page-window-bridge/startPageWindowBridge.ts +++ b/src/renderer/src/app/model/page-window-bridge/startPageWindowBridge.ts @@ -1,5 +1,6 @@ import { api, onAppEvent } from '@/shared/api'; import { usePageStore } from '@/entities/page'; +import { loadBrowsers } from '@/features/browser/open-in-browser'; import { handlePageWindowEvent } from './handlePageWindowEvent'; /** The website window's link to the main process: the page's state, for its toolbar, and its shortcut. Returns a cleanup. */ @@ -8,5 +9,6 @@ export async function startPageWindowBridge(): Promise<() => void> { // Asked once listening, which the main process waits for to focus the address bar (Ctrl/Cmd+L while this loaded). // Applied as its reply arrives, in order with the events around it (a later one is newer). usePageStore.getState().setPage(await api.getPageState()); + void loadBrowsers(); return off; } diff --git a/src/renderer/src/app/model/page-window-bridge/types.ts b/src/renderer/src/app/model/page-window-bridge/types.ts index 30cee2c..e1fe168 100644 --- a/src/renderer/src/app/model/page-window-bridge/types.ts +++ b/src/renderer/src/app/model/page-window-bridge/types.ts @@ -1,7 +1,8 @@ +import type { PAGE_WINDOW_EVENTS } from '@common/constants'; import type { AppEventOf } from '../bridge/types'; /** The events the website window acts on: the rest are the editor's. */ -export type PageWindowEventType = 'page-state' | 'command'; +export type PageWindowEventType = 'page-state' | 'command' | (typeof PAGE_WINDOW_EVENTS)[number]; /** One handler per event the website window acts on, given its own member. */ export type PageWindowEventHandlers = { [T in PageWindowEventType]: (event: AppEventOf) => void }; diff --git a/src/renderer/src/entities/browser/index.ts b/src/renderer/src/entities/browser/index.ts new file mode 100644 index 0000000..470a54d --- /dev/null +++ b/src/renderer/src/entities/browser/index.ts @@ -0,0 +1,2 @@ +export { selectShownBrowsers, useBrowserStore } from './model/store'; +export { BrowserIcon } from './ui/BrowserIcon'; diff --git a/src/renderer/src/entities/browser/model/store/index.ts b/src/renderer/src/entities/browser/model/store/index.ts new file mode 100644 index 0000000..64387bd --- /dev/null +++ b/src/renderer/src/entities/browser/model/store/index.ts @@ -0,0 +1,3 @@ +export { selectShownBrowsers } from './selectShownBrowsers'; +export type { BrowserStore } from './types'; +export { useBrowserStore } from './useBrowserStore'; diff --git a/src/renderer/src/entities/browser/model/store/selectShownBrowsers.ts b/src/renderer/src/entities/browser/model/store/selectShownBrowsers.ts new file mode 100644 index 0000000..0d9c1ec --- /dev/null +++ b/src/renderer/src/entities/browser/model/store/selectShownBrowsers.ts @@ -0,0 +1,5 @@ +import type { BrowserInfo } from '@common/types'; +import type { BrowserStore } from './types'; + +/** The browsers offered beside the address bar: the ones not turned off. Select with `useShallow`. */ +export const selectShownBrowsers = (s: BrowserStore): BrowserInfo[] => s.browsers.filter((b) => !b.hidden); diff --git a/src/renderer/src/entities/browser/model/store/types.ts b/src/renderer/src/entities/browser/model/store/types.ts new file mode 100644 index 0000000..7ebb1fa --- /dev/null +++ b/src/renderer/src/entities/browser/model/store/types.ts @@ -0,0 +1,10 @@ +import type { BrowserInfo } from '@common/types'; + +export interface BrowserStore { + /** The browsers on this computer and the ones the user added, hidden ones too. */ + browsers: BrowserInfo[]; + /** Whether they have been looked for yet (the menu shows a spinner until then). */ + loaded: boolean; + + setAll(browsers: BrowserInfo[]): void; +} diff --git a/src/renderer/src/entities/browser/model/store/useBrowserStore.ts b/src/renderer/src/entities/browser/model/store/useBrowserStore.ts new file mode 100644 index 0000000..53f4a68 --- /dev/null +++ b/src/renderer/src/entities/browser/model/store/useBrowserStore.ts @@ -0,0 +1,9 @@ +import { create } from 'zustand'; +import type { BrowserStore } from './types'; + +/** The other browsers (mirrors the main process's list, which announces every change). */ +export const useBrowserStore = create()((set) => ({ + browsers: [], + loaded: false, + setAll: (browsers) => set({ browsers, loaded: true }), +})); diff --git a/src/renderer/src/entities/browser/ui/BrowserIcon.tsx b/src/renderer/src/entities/browser/ui/BrowserIcon.tsx new file mode 100644 index 0000000..671cb6a --- /dev/null +++ b/src/renderer/src/entities/browser/ui/BrowserIcon.tsx @@ -0,0 +1,17 @@ +import type { BrowserInfo } from '@common/types'; +import { icons } from '@/shared/config'; +import { cn } from '@/shared/lib'; +import { Icon } from '@/shared/ui/icon'; + +export interface BrowserIconProps { + browser: Pick; + /** Its side, in pixels (16 by default). */ + size?: number; + className?: string; +} + +/** A browser's icon from the system, or a plain browser glyph when the system gives none. */ +export function BrowserIcon({ browser, size = 16, className }: BrowserIconProps) { + if (!browser.icon) return ; + return ; +} diff --git a/src/renderer/src/features/browser/manage-browsers/index.ts b/src/renderer/src/features/browser/manage-browsers/index.ts new file mode 100644 index 0000000..0216771 --- /dev/null +++ b/src/renderer/src/features/browser/manage-browsers/index.ts @@ -0,0 +1 @@ +export { BrowserSettings } from './ui/BrowserSettings'; diff --git a/src/renderer/src/features/browser/manage-browsers/model/addBrowser.ts b/src/renderer/src/features/browser/manage-browsers/model/addBrowser.ts new file mode 100644 index 0000000..886da9c --- /dev/null +++ b/src/renderer/src/features/browser/manage-browsers/model/addBrowser.ts @@ -0,0 +1,12 @@ +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Asks for a browser's program and adds it (the list follows as `browsers-changed`); nothing when none is picked. */ +export async function addBrowser(): Promise { + try { + const added = await api.addBrowser(); + if (added) toast({ title: `Added ${added.name}`, description: 'It is offered beside the address bar.', tone: 'success' }); + } catch (err) { + toast({ title: 'Could not add that browser', description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/manage-browsers/model/index.ts b/src/renderer/src/features/browser/manage-browsers/model/index.ts new file mode 100644 index 0000000..42876ea --- /dev/null +++ b/src/renderer/src/features/browser/manage-browsers/model/index.ts @@ -0,0 +1,3 @@ +export { addBrowser } from './addBrowser'; +export { removeBrowser } from './removeBrowser'; +export { showBrowser } from './showBrowser'; diff --git a/src/renderer/src/features/browser/manage-browsers/model/removeBrowser.ts b/src/renderer/src/features/browser/manage-browsers/model/removeBrowser.ts new file mode 100644 index 0000000..a10e1a8 --- /dev/null +++ b/src/renderer/src/features/browser/manage-browsers/model/removeBrowser.ts @@ -0,0 +1,12 @@ +import type { BrowserInfo } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Removes a browser the user added. */ +export async function removeBrowser(browser: Pick): Promise { + try { + await api.removeBrowser(browser.id); + } catch (err) { + toast({ title: `Could not remove ${browser.name}`, description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/manage-browsers/model/showBrowser.ts b/src/renderer/src/features/browser/manage-browsers/model/showBrowser.ts new file mode 100644 index 0000000..1e65c66 --- /dev/null +++ b/src/renderer/src/features/browser/manage-browsers/model/showBrowser.ts @@ -0,0 +1,12 @@ +import type { BrowserInfo } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Offers a browser beside the address bar again, or stops offering it. */ +export async function showBrowser(browser: Pick, shown: boolean): Promise { + try { + await api.setBrowserHidden(browser.id, !shown); + } catch (err) { + toast({ title: `Could not change ${browser.name}`, description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/manage-browsers/ui/BrowserSettingRow.tsx b/src/renderer/src/features/browser/manage-browsers/ui/BrowserSettingRow.tsx new file mode 100644 index 0000000..2ac11a3 --- /dev/null +++ b/src/renderer/src/features/browser/manage-browsers/ui/BrowserSettingRow.tsx @@ -0,0 +1,25 @@ +import type { BrowserInfo } from '@common/types'; +import { icons } from '@/shared/config'; +import { IconButton } from '@/shared/ui/icon-button'; +import { Switch } from '@/shared/ui/switch'; +import { BrowserIcon } from '@/entities/browser'; +import { removeBrowser, showBrowser } from '../model'; + +/** Words for each engine: what the app can do with a browser depends on it. */ +const ENGINE_LABEL: Record = { chromium: 'Chromium', gecko: 'Firefox (Gecko)', webkit: 'WebKit', unknown: 'Other' }; + +/** A browser in Settings: whether it is offered beside the address bar, and (one the user added) removing it. */ +export function BrowserSettingRow({ browser }: { browser: BrowserInfo }) { + const detail = [ENGINE_LABEL[browser.engine], browser.version, browser.added ? 'added by you' : null].filter(Boolean).join(' · '); + return ( +
+ + + {browser.name} + {detail} + + {browser.added ? void removeBrowser(browser)} /> : null} + void showBrowser(browser, on)} aria-label={`Offer ${browser.name}`} /> +
+ ); +} diff --git a/src/renderer/src/features/browser/manage-browsers/ui/BrowserSettings.tsx b/src/renderer/src/features/browser/manage-browsers/ui/BrowserSettings.tsx new file mode 100644 index 0000000..3281324 --- /dev/null +++ b/src/renderer/src/features/browser/manage-browsers/ui/BrowserSettings.tsx @@ -0,0 +1,23 @@ +import { icons } from '@/shared/config'; +import { Button } from '@/shared/ui/button'; +import { Icon } from '@/shared/ui/icon'; +import { useBrowserStore } from '@/entities/browser'; +import { addBrowser } from '../model'; +import { BrowserSettingRow } from './BrowserSettingRow'; + +/** Settings › Browsers: the browsers found and added, which are offered beside the address bar, and adding one. */ +export function BrowserSettings() { + const browsers = useBrowserStore((s) => s.browsers); + return ( +
+ Browsers +

The ones switched on are offered beside the address bar, to open the page in.

+ {browsers.map((browser) => ( + + ))} + +
+ ); +} diff --git a/src/renderer/src/features/browser/open-in-browser/index.ts b/src/renderer/src/features/browser/open-in-browser/index.ts new file mode 100644 index 0000000..3e2de87 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/index.ts @@ -0,0 +1,2 @@ +export { loadBrowsers, openInBrowser } from './model'; +export { BrowserMenuButton } from './ui/BrowserMenuButton'; diff --git a/src/renderer/src/features/browser/open-in-browser/model/index.ts b/src/renderer/src/features/browser/open-in-browser/model/index.ts new file mode 100644 index 0000000..0b1d439 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/index.ts @@ -0,0 +1,3 @@ +export { loadBrowsers } from './loadBrowsers'; +export { matchesBrowser } from './matchesBrowser'; +export { openInBrowser } from './openInBrowser'; diff --git a/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts b/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts new file mode 100644 index 0000000..38341f6 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts @@ -0,0 +1,8 @@ +import { api } from '@/shared/api'; +import { useBrowserStore } from '@/entities/browser'; + +/** Asks for the browsers (the main process looks again when its list is a minute old); a failure leaves the list as it was, looked for. */ +export async function loadBrowsers(): Promise { + const { setAll } = useBrowserStore.getState(); + setAll(await api.listBrowsers().catch(() => useBrowserStore.getState().browsers)); +} diff --git a/src/renderer/src/features/browser/open-in-browser/model/matchesBrowser.ts b/src/renderer/src/features/browser/open-in-browser/model/matchesBrowser.ts new file mode 100644 index 0000000..c63b9c7 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/matchesBrowser.ts @@ -0,0 +1,10 @@ +import type { BrowserInfo } from '@common/types'; + +/** Whether a browser answers the menu's search: every word in its name, engine or version. */ +export function matchesBrowser(browser: BrowserInfo, query: string): boolean { + const text = `${browser.name} ${browser.engine} ${browser.version ?? ''}`.toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .every((word) => text.includes(word)); +} diff --git a/src/renderer/src/features/browser/open-in-browser/model/openInBrowser.ts b/src/renderer/src/features/browser/open-in-browser/model/openInBrowser.ts new file mode 100644 index 0000000..8762bba --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/openInBrowser.ts @@ -0,0 +1,13 @@ +import type { BrowserInfo } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; +import { usePageStore } from '@/entities/page'; + +/** Opens the page shown in another browser, with its everyday profile. */ +export async function openInBrowser(browser: Pick): Promise { + try { + await api.openInBrowser(browser.id, usePageStore.getState().page.url); + } catch (err) { + toast({ title: `Could not open ${browser.name}`, description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserCluster.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserCluster.tsx new file mode 100644 index 0000000..90d968d --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserCluster.tsx @@ -0,0 +1,18 @@ +import type { BrowserInfo } from '@common/types'; +import { BrowserIcon } from '@/entities/browser'; +import { CLUSTER_ICON, CLUSTER_SIZE } from './constants'; + +/** Up to four browsers' icons in a 2×2 grid, as the button beside the address bar shows them; the rest of the grid dashed rings. */ +export function BrowserCluster({ browsers }: { browsers: BrowserInfo[] }) { + const shown = browsers.slice(0, CLUSTER_SIZE); + return ( + + {shown.map((browser) => ( + + ))} + {Array.from({ length: CLUSTER_SIZE - shown.length }, (_, i) => ( + + ))} + + ); +} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx new file mode 100644 index 0000000..a84b4d8 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import type { BrowserInfo } from '@common/types'; +import { icons, KEY } from '@/shared/config'; +import { webAddress } from '@/shared/lib'; +import { Icon } from '@/shared/ui/icon'; +import { IconButton } from '@/shared/ui/icon-button'; +import { Input } from '@/shared/ui/input'; +import { Spinner } from '@/shared/ui/spinner'; +import { selectShownBrowsers, useBrowserStore } from '@/entities/browser'; +import { usePageStore } from '@/entities/page'; +import { matchesBrowser, openInBrowser } from '../model'; +import { BrowserRow } from './BrowserRow'; + +export interface BrowserMenuProps { + onClose(): void; + /** Shows Settings › Browsers (only where the editor's settings are). */ + onShowSettings?: () => void; +} + +/** The browser menu's content: a search, and each browser offered; choosing one opens the page there. */ +export function BrowserMenu({ onClose, onShowSettings }: BrowserMenuProps) { + const [query, setQuery] = useState(''); + const browsers = useBrowserStore(useShallow(selectShownBrowsers)); + const loaded = useBrowserStore((s) => s.loaded); + const onWeb = usePageStore((s) => webAddress(s.page.url) !== ''); + const matches = browsers.filter((b) => matchesBrowser(b, query.trim())); + const open = (browser: BrowserInfo) => { + onClose(); + void openInBrowser(browser); + }; + + return ( +
+
+ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === KEY.enter && onWeb && matches[0]) open(matches[0]); + }} + placeholder="Search browsers…" + aria-label="Search browsers" + leading={} + className="flex-1" + /> + {onShowSettings ? : null} +
+ {onWeb ? null :

Open a website first, then open it in another browser from here.

} +
+ {matches.map((browser) => ( + + ))} + {!loaded ? : null} + {loaded && !matches.length ? ( +

{browsers.length ? 'No browser matches.' : 'No other browsers were found. Add one in Settings › Browsers.'}

+ ) : null} +
+
+ ); +} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenuButton.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenuButton.tsx new file mode 100644 index 0000000..57a7366 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenuButton.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; +import { Popover } from '@/shared/ui/popover'; +import { Tooltip } from '@/shared/ui/tooltip'; +import { selectShownBrowsers, useBrowserStore } from '@/entities/browser'; +import { loadBrowsers } from '../model'; +import { BrowserCluster } from './BrowserCluster'; +import { BrowserMenu, type BrowserMenuProps } from './BrowserMenu'; + +/** Beside the address bar: the browsers' icons, opening the menu that opens the page in one of them. */ +export function BrowserMenuButton({ onShowSettings }: Pick) { + const [anchor, setAnchor] = useState(null); + const [open, setOpen] = useState(false); + const browsers = useBrowserStore(useShallow(selectShownBrowsers)); + return ( + <> + + + + + setOpen(false)} + onShowSettings={ + onShowSettings && + (() => { + setOpen(false); + onShowSettings(); + }) + } + /> + + + ); +} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx new file mode 100644 index 0000000..25abd89 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx @@ -0,0 +1,27 @@ +import type { BrowserInfo } from '@common/types'; +import { BrowserIcon } from '@/entities/browser'; + +export interface BrowserRowProps { + browser: BrowserInfo; + /** The page can't be opened elsewhere (none is shown, or it isn't on the web). */ + disabled: boolean; + onOpen(browser: BrowserInfo): void; +} + +/** A browser in the menu: its icon, name and version; choosing it opens the page there. */ +export function BrowserRow({ browser, disabled, onOpen }: BrowserRowProps) { + return ( + + ); +} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/constants.ts b/src/renderer/src/features/browser/open-in-browser/ui/constants.ts new file mode 100644 index 0000000..ff641bd --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/ui/constants.ts @@ -0,0 +1,5 @@ +/** How many browsers' icons the button shows, in a 2×2 grid; empty places are drawn as dashed rings. */ +export const CLUSTER_SIZE = 4; + +/** Each icon's side in the button's grid, in pixels. */ +export const CLUSTER_ICON = 10; diff --git a/src/renderer/src/pages/editor/ui/EditorPage/EditorPage.tsx b/src/renderer/src/pages/editor/ui/EditorPage/EditorPage.tsx index cc0694c..4f980f0 100644 --- a/src/renderer/src/pages/editor/ui/EditorPage/EditorPage.tsx +++ b/src/renderer/src/pages/editor/ui/EditorPage/EditorPage.tsx @@ -127,7 +127,7 @@ export function EditorPage() { {showPreview ? ( {/* The sidebar animating in or out can move the preview without resizing it. */} - + ) : null} diff --git a/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx b/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx index 9009139..8c5dee0 100644 --- a/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx +++ b/src/renderer/src/widgets/command-palette/ui/AppCommandPalette.tsx @@ -2,8 +2,9 @@ import { useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { SHORTCUT } from '@common/constants'; import { icons } from '@/shared/config'; -import { fileName, hostOf, pathOf } from '@/shared/lib'; +import { fileName, hostOf, pathOf, webAddress } from '@/shared/lib'; import { CommandPalette, type CommandGroup } from '@/shared/ui/command-palette'; +import { selectShownBrowsers, useBrowserStore } from '@/entities/browser'; import { selectActiveSource, selectActiveTab, useTabStore } from '@/entities/editor-tab'; import { selectOverrideList, useOverrideStore } from '@/entities/override'; import { useRenderLog, useStoreLog } from '@/entities/inspector'; @@ -20,6 +21,7 @@ import { usePageFiles } from '../model/files'; import { usePalette } from '../model/palette'; import { useActionGroup } from '../model/useActionGroup'; import { sourceActions, useOriginalSources } from '../model/sources'; +import { browserItems } from './browserItems'; import { KIND_ICON, OVERRIDE_ITEM_PREFIX, WORKSPACE_ITEM_PREFIX } from './constants'; import { fileTabItems } from './fileTabItems'; import { inspectItems, type InspectLog } from './inspectItems'; @@ -52,6 +54,8 @@ export function AppCommandPalette({ onShowSettings, onShowExplorer, onFocusAddre const workspaces = useWorkspaceStore((s) => s.workspaces); const activeWorkspaceId = useWorkspaceStore((s) => s.activeId); const detached = usePageStore((s) => s.page.detached); + const onWeb = usePageStore((s) => webAddress(s.page.url) !== ''); + const browsers = useBrowserStore(useShallow(selectShownBrowsers)); const recordingRenders = useRenderLog((s) => s.recording); const recordingStores = useStoreLog((s) => s.recording); const runGroup = useActionGroup(onNewAction); @@ -94,6 +98,7 @@ export function AppCommandPalette({ onShowSettings, onShowExplorer, onFocusAddre { id: 'network', label: 'Show network', icon: icons.NetworkIcon, keywords: ['requests', 'fetch', 'xhr', 'api', 'json', 'graphql', 'response'], onSelect: onShowNetwork }, { id: 'url', label: 'Go to URL…', icon: icons.GlobeIcon, shortcut: SHORTCUT.focusUrl, onSelect: onFocusAddressBar }, { id: 'page-window', label: move.label, icon: move.icon, keywords: ['window', 'screen', 'monitor', 'detach', 'pop out', 'attach'], onSelect: () => void move.run() }, + ...browserItems(browsers, onWeb), ...inspectItems({ renders: recordingRenders, stores: recordingStores }, onShowLog), { id: 'devtools', label: 'Open DevTools for the page', icon: icons.DevToolsIcon, shortcut: SHORTCUT.pageDevTools, onSelect: () => void openPageDevTools() }, ...newRuleItems(), @@ -134,7 +139,7 @@ export function AppCommandPalette({ onShowSettings, onShowExplorer, onFocusAddre ], }; return [files, sources, overrideGroup, ruleGroup, runGroup, workspaceGroup, actions].filter((g) => g.items.length); - }, [open, files, sources, overrides, rules, runGroup, active, activeSource, workspaces, activeWorkspaceId, detached, onShowSettings, onShowExplorer, onFocusAddressBar, onSwitchWorkspace, onNewWorkspace, onToggleConsole, onShowNetwork, recordingRenders, recordingStores, onShowLog]); + }, [open, files, sources, overrides, rules, runGroup, active, activeSource, workspaces, activeWorkspaceId, detached, browsers, onWeb, onShowSettings, onShowExplorer, onFocusAddressBar, onSwitchWorkspace, onNewWorkspace, onToggleConsole, onShowNetwork, recordingRenders, recordingStores, onShowLog]); return ; } diff --git a/src/renderer/src/widgets/command-palette/ui/browserItems.ts b/src/renderer/src/widgets/command-palette/ui/browserItems.ts new file mode 100644 index 0000000..d6c909f --- /dev/null +++ b/src/renderer/src/widgets/command-palette/ui/browserItems.ts @@ -0,0 +1,19 @@ +import type { BrowserInfo } from '@common/types'; +import { icons } from '@/shared/config'; +import type { CommandItem } from '@/shared/ui/command-palette'; +import { openInBrowser } from '@/features/browser/open-in-browser'; + +/** A palette item's id prefix for opening the page in a browser. */ +const OPEN_IN_PREFIX = 'open-in:'; + +/** Opening the page in each browser offered, while it is on the web. */ +export function browserItems(browsers: BrowserInfo[], onWeb: boolean): CommandItem[] { + if (!onWeb) return []; + return browsers.map((browser) => ({ + id: `${OPEN_IN_PREFIX}${browser.id}`, + label: `Open in ${browser.name}`, + icon: icons.BrowserIcon, + keywords: ['browser', 'other browser', browser.engine], + onSelect: () => void openInBrowser(browser), + })); +} diff --git a/src/renderer/src/widgets/page-preview/ui/PagePreview/PagePreview.tsx b/src/renderer/src/widgets/page-preview/ui/PagePreview/PagePreview.tsx index 2acb284..b5b1027 100644 --- a/src/renderer/src/widgets/page-preview/ui/PagePreview/PagePreview.tsx +++ b/src/renderer/src/widgets/page-preview/ui/PagePreview/PagePreview.tsx @@ -12,7 +12,7 @@ import { usePageSnapshot } from './usePageSnapshot'; * this panel's host box; we keep its bounds in sync, and swap it for a still * snapshot while an overlay (palette, menu, dialog) is open. */ -export function PagePreview({ placement = 'editor', suspended = false, layoutKey, addressBarRef }: PagePreviewProps) { +export function PagePreview({ placement = 'editor', suspended = false, layoutKey, addressBarRef, onShowBrowserSettings }: PagePreviewProps) { const hasPage = usePageStore(selectHasPage); const overlayOpen = useOverlayStore(selectAnyOverlayOpen); const frozen = overlayOpen && hasPage; @@ -24,7 +24,7 @@ export function PagePreview({ placement = 'editor', suspended = false, layoutKey return (
- +
diff --git a/src/renderer/src/widgets/page-preview/ui/PagePreview/PreviewToolbar.tsx b/src/renderer/src/widgets/page-preview/ui/PagePreview/PreviewToolbar.tsx index 213f2d2..5f609e9 100644 --- a/src/renderer/src/widgets/page-preview/ui/PagePreview/PreviewToolbar.tsx +++ b/src/renderer/src/widgets/page-preview/ui/PagePreview/PreviewToolbar.tsx @@ -2,23 +2,24 @@ import { SHORTCUT } from '@common/constants'; import { icons } from '@/shared/config'; import { IconButton } from '@/shared/ui/icon-button'; import { usePageStore } from '@/entities/page'; +import { BrowserMenuButton } from '@/features/browser/open-in-browser'; import { goBack, goForward, openPageDevTools, reloadPage } from '@/features/navigate-page'; import { AddressBar } from '../AddressBar'; import { MOVE_BUTTON } from './constants'; import { PickButton } from './PickButton'; import type { PagePreviewProps, PreviewPlacement } from './types'; -export interface PreviewToolbarProps extends Pick { +export interface PreviewToolbarProps extends Pick { hasPage: boolean; placement: PreviewPlacement; } /** - * The preview's navigation bar: back, forward, reload (spinning while the page loads), the address, the + * The preview's navigation bar: back, forward, reload (spinning while the page loads), the other browsers, the address, the * page's DevTools, picking an element (in the editor, which shows what was picked), and moving the website to its own * window or back. */ -export function PreviewToolbar({ hasPage, placement, addressBarRef }: PreviewToolbarProps) { +export function PreviewToolbar({ hasPage, placement, addressBarRef, onShowBrowserSettings }: PreviewToolbarProps) { const move = MOVE_BUTTON[placement]; const canGoBack = usePageStore((s) => s.page.canGoBack); const canGoForward = usePageStore((s) => s.page.canGoForward); @@ -37,6 +38,7 @@ export function PreviewToolbar({ hasPage, placement, addressBarRef }: PreviewToo onClick={() => void reloadPage()} className={loading ? '[&_svg]:animate-spin-slow' : undefined} /> + {placement === 'editor' ? : null} void openPageDevTools()} /> diff --git a/src/renderer/src/widgets/page-preview/ui/PagePreview/types.ts b/src/renderer/src/widgets/page-preview/ui/PagePreview/types.ts index 5c7ce20..a9be58f 100644 --- a/src/renderer/src/widgets/page-preview/ui/PagePreview/types.ts +++ b/src/renderer/src/widgets/page-preview/ui/PagePreview/types.ts @@ -13,6 +13,8 @@ export interface PagePreviewProps { */ layoutKey?: unknown; addressBarRef?: (el: HTMLInputElement | null) => void; + /** Shows Settings › Browsers, from the browser menu's gear (only where the editor's settings are). */ + onShowBrowserSettings?: () => void; } /** The still shown while frozen: an image, CAPTURE_FAILED if capturing failed, or null while pending. */ diff --git a/src/renderer/src/widgets/settings-panel/ui/SettingsPanel.tsx b/src/renderer/src/widgets/settings-panel/ui/SettingsPanel.tsx index fdde06f..58e6d41 100644 --- a/src/renderer/src/widgets/settings-panel/ui/SettingsPanel.tsx +++ b/src/renderer/src/widgets/settings-panel/ui/SettingsPanel.tsx @@ -1,8 +1,9 @@ import { Switch } from '@/shared/ui/switch'; import { SETTING_META, useSettingsStore } from '@/entities/settings'; +import { BrowserSettings } from '@/features/browser/manage-browsers'; import { setSetting } from '@/features/update-settings'; -/** Sidebar view with every setting as a switch plus a one-line explanation. */ +/** Sidebar view with every setting as a switch plus a one-line explanation, then the browsers offered beside the address bar. */ export function SettingsPanel() { const settings = useSettingsStore((s) => s.settings); return ( @@ -20,6 +21,7 @@ export function SettingsPanel() { void setSetting(key, on)} aria-label={label} /> ))} +
); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d325c05..14ea7d1 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -20,6 +20,12 @@ export const ACTIONS_WINDOW_HASH = 'actions-window'; */ export const ACTIONS_WINDOW_EVENTS = ['actions-changed', 'actions-window', 'frames-changed', 'settings-changed', 'workspaces-changed'] as const satisfies readonly AppEvent['type'][]; +/** + * The events the website's own window is sent besides its page's state and its shortcut: what its toolbar's menus + * show. Its UI has a handler for each, so one added here fails typecheck until it is handled there. + */ +export const PAGE_WINDOW_EVENTS = ['browsers-changed'] as const satisfies readonly AppEvent['type'][]; + /** * The events that can carry megabytes (a batch of commits of hundreds of components, of requests with their * initiators): sent to a window as JSON. A string is copied as one block by IPC and by the preload's bridge, and diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts index 922557f..fb62289 100644 --- a/src/shared/ipcChannels.ts +++ b/src/shared/ipcChannels.ts @@ -87,6 +87,12 @@ export const IPC_CHANNEL = { listHeldRequests: 'network:held', resumeHeldRequest: 'network:resume', + listBrowsers: 'browsers:list', + openInBrowser: 'browsers:open', + addBrowser: 'browsers:add', + removeBrowser: 'browsers:remove', + setBrowserHidden: 'browsers:hidden', + getActionsWindow: 'actions-window:state', detachActions: 'actions-window:detach', attachActions: 'actions-window:attach', diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 645cb14..1e30429 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -1,4 +1,5 @@ import type { ActionInput, ActionPatch, ActionsWindowState, ConsoleAction } from './actions'; +import type { BrowsersApi } from './browsersApi'; import type { ConsoleEntry, ConsoleFrame, ConsoleProperty } from './console'; import type { InspectorApi } from './inspector'; import type { WireEvent } from './events'; @@ -15,7 +16,7 @@ import type { AppInfo, UpdateState } from './updates'; import type { Workspace, WorkspacePatch, WorkspacesState } from './workspaces'; /** The API exposed to the renderer as `window.consoleEditor`. */ -export interface ConsoleEditorApi extends InspectorApi, NetworkApi { +export interface ConsoleEditorApi extends InspectorApi, NetworkApi, BrowsersApi { navigate(url: string): Promise; reload(): Promise; goBack(): Promise; diff --git a/src/shared/types/browsers.ts b/src/shared/types/browsers.ts new file mode 100644 index 0000000..5829d5a --- /dev/null +++ b/src/shared/types/browsers.ts @@ -0,0 +1,20 @@ +/** The engines the app tells apart: what it can do with a browser depends on its engine. */ +export const BROWSER_ENGINES = ['chromium', 'gecko', 'webkit', 'unknown'] as const; + +export type BrowserEngine = (typeof BROWSER_ENGINES)[number]; + +/** A browser installed on this computer, or one the user added. */ +export interface BrowserInfo { + /** Stable across scans: the desktop entry's, the app's or the registry key's name, or `added:<8 hex>`. */ + id: string; + name: string; + engine: BrowserEngine; + /** Its version, once read (null until then, or when it can't be). */ + version: string | null; + /** Its icon from the system, as a data URL; null when the system has none to give. */ + icon: string | null; + /** The user added it (Settings › Browsers), so it can be removed there. */ + added: boolean; + /** Turned off in Settings › Browsers: not offered beside the address bar. */ + hidden: boolean; +} diff --git a/src/shared/types/browsersApi.ts b/src/shared/types/browsersApi.ts new file mode 100644 index 0000000..21f64e8 --- /dev/null +++ b/src/shared/types/browsersApi.ts @@ -0,0 +1,15 @@ +import type { BrowserInfo } from './browsers'; + +/** The other browsers' part of the API exposed to the renderer (`ConsoleEditorApi`). */ +export interface BrowsersApi { + /** The browsers on this computer and the ones the user added, hidden ones too; looked for again at most once a minute. */ + listBrowsers(): Promise; + /** Opens an http(s) address in one of them, with its everyday profile. */ + openInBrowser(id: string, url: string): Promise; + /** Asks for a browser's program with the system's dialog and adds it; null when none was picked. */ + addBrowser(): Promise; + /** Removes a browser the user added. */ + removeBrowser(id: string): Promise; + /** Offers a browser beside the address bar again, or stops offering it. */ + setBrowserHidden(id: string, hidden: boolean): Promise; +} diff --git a/src/shared/types/events.ts b/src/shared/types/events.ts index 7a6afb9..e0883f1 100644 --- a/src/shared/types/events.ts +++ b/src/shared/types/events.ts @@ -1,4 +1,5 @@ import type { ActionsWindowState, ConsoleAction } from './actions'; +import type { BrowserInfo } from './browsers'; import type { HeldRequest } from './breakpoints'; import type { ConsoleEntry, ConsoleFrame } from './console'; import type { FrameStack, InspectedComponent, InspectHover, RenderCommit, StoreAction } from './inspector'; @@ -85,6 +86,8 @@ export type AppEvent = | { type: 'actions-window'; state: ActionsWindowState } /** A window changed the settings (the others show them as they are now). */ | { type: 'settings-changed'; settings: Settings } + /** The browsers on this computer were looked for again, a version was read, or one was added, removed, hidden or shown. */ + | { type: 'browsers-changed'; browsers: BrowserInfo[] } /** The window is closing: write pending drafts, then call `sessionFlushed`. */ | { type: 'flush-session' } | { type: 'update'; state: UpdateState }; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index a4168ba..3f4cb53 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -8,6 +8,9 @@ export type { ActionInput, ActionPatch, ActionsWindowState, ConsoleAction } from export type { Breakpoint, BreakpointStage, FailReason, HeldAction, HeldActionType, HeldRequest, HeldResponse } from './breakpoints'; export { BREAKPOINT_STAGES, FAIL_REASONS } from './breakpoints'; export type { ConsoleEditorApi } from './api'; +export type { BrowserEngine, BrowserInfo } from './browsers'; +export { BROWSER_ENGINES } from './browsers'; +export type { BrowsersApi } from './browsersApi'; export type { ConsoleEntry, ConsoleFrame, ConsoleLevel, ConsoleLocation, ConsoleProperty, ConsoleSource, ConsoleValue, ConsoleValueKind } from './console'; export { CONSOLE_LEVELS } from './console'; export type { AppEvent, EngineEvent, WireEvent } from './events'; diff --git a/test/e2e/browsers.e2e.test.ts b/test/e2e/browsers.e2e.test.ts new file mode 100644 index 0000000..8480709 --- /dev/null +++ b/test/e2e/browsers.e2e.test.ts @@ -0,0 +1,150 @@ +/** + * Other browsers, in the built app on Linux: a fake Chromium installed as a launcher (whose program writes down the + * address it was given) is offered beside the address bar, in both windows and the palette; opening the page there, + * searching, turning it off in Settings, and adding and removing a browser by its program. + */ +import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { startFixtureSite, type FixtureSite } from '../fixtures/site'; + +const root = resolve(__dirname, '../..'); +const built = existsSync(join(root, 'out/main/index.js')); +// Chromium's sandbox can't start as root (containers). +const sandboxArgs = process.getuid?.() === 0 ? ['--no-sandbox'] : []; + +const EDITOR_URL = /\/renderer\/index\.html$/; +const PAGE_WINDOW_URL = /\/renderer\/index\.html#page-window$/; +const FAKE_ID = 'desktop:fake-chromium.desktop'; + +/** Polls until `fn` returns a truthy value (usable outside tests, unlike expect.poll). */ +async function waitFor(fn: () => T | undefined, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +/** An executable shell script. */ +function script(path: string, body: string): string { + writeFileSync(path, `#!/bin/sh\n${body}\n`); + chmodSync(path, 0o755); + return path; +} + +/** What a fake browser was last asked to open, '' before that. */ +const opened = (file: string) => readFile(file, 'utf8').then((text) => text.trim(), () => ''); + +describe.skipIf(!built || process.platform !== 'linux')('Other browsers', () => { + let site: FixtureSite; + let dir: string; + let app: ElectronApplication; + let win: Page; + let openedFile: string; + let addedFile: string; + let addedProgram: string; + + beforeAll(async () => { + site = await startFixtureSite(); + dir = await mkdtemp(join(tmpdir(), 'console-editor-e2e-browsers-')); + openedFile = join(dir, 'opened.txt'); + addedFile = join(dir, 'added.txt'); + const version = 'if [ "$1" = "--version" ]; then echo "Fake Chromium 99.0.1"; exit 0; fi'; + const fake = script(join(dir, 'fake-chromium'), `${version}\necho "$@" > "${openedFile}"`); + addedProgram = script(join(dir, 'nightly'), `echo "$@" > "${addedFile}"`); + mkdirSync(join(dir, 'data/applications'), { recursive: true }); + mkdirSync(join(dir, 'system'), { recursive: true }); + writeFileSync(join(dir, 'data/applications/fake-chromium.desktop'), `[Desktop Entry]\nType=Application\nName=Fake Chromium\nExec=${fake} %u\nCategories=Network;WebBrowser;\n`); + + app = await electron.launch({ + args: [...sandboxArgs, root], + cwd: root, + // Only this test's launcher, and no system ones (Flatpak's and Snap's may still add theirs). + env: { ...process.env, CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, + }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + const bar = win.getByTestId('address-bar'); + await bar.fill(`${site.url}/`); + await bar.press('Enter'); + // The page's title shows once it has loaded (expect.poll can't run before the tests). + await win.getByText('Fixture site').first().waitFor({ timeout: 20_000 }); + }); + + afterAll(async () => { + await app?.close(); + await site?.close(); + await rm(dir, { recursive: true, force: true }); + }); + + const row = (page: Page, id = FAKE_ID) => page.locator(`[data-testid="browser-row"][data-browser-id="${id}"]`); + + it('offers the installed browser beside the address bar, with its version, and opens the page there', async () => { + await win.getByTestId('browser-menu-button').click(); + await expect.poll(() => row(win).innerText()).toContain('Fake Chromium'); + await expect.poll(() => row(win).innerText(), { timeout: 10_000 }).toContain('99.0.1'); + + // The search narrows the list; Enter opens the first match. + const search = win.getByRole('textbox', { name: 'Search browsers' }); + await search.fill('nothing like it'); + await win.getByText('No browser matches.').waitFor(); + await search.fill('fake'); + await search.press('Enter'); + await expect.poll(() => opened(openedFile), { timeout: 10_000 }).toBe(`${site.url}/`); + await expect.poll(() => win.getByTestId('browser-menu').count()).toBe(0); + }); + + it('opens it from the palette too', async () => { + await rm(openedFile, { force: true }); + await win.keyboard.press('Control+K'); + await win.keyboard.type('Open in Fake'); + await win.getByRole('option', { name: /Open in Fake Chromium/ }).click(); + await expect.poll(() => opened(openedFile), { timeout: 10_000 }).toBe(`${site.url}/`); + }); + + it('stops offering a browser turned off in Settings, and adds and removes one by its program', async () => { + await win.getByTestId('rail-settings').click(); + const offer = win.getByRole('switch', { name: 'Offer Fake Chromium' }); + await offer.click(); + await expect.poll(() => offer.getAttribute('aria-checked')).toBe('false'); + await win.getByTestId('browser-menu-button').click(); + await win.getByText('No other browsers were found. Add one in Settings › Browsers.').waitFor(); + await win.keyboard.press('Escape'); + await offer.click(); + await expect.poll(() => offer.getAttribute('aria-checked')).toBe('true'); + + await app.evaluate(({ dialog }, path) => { + dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [path] })) as unknown as typeof dialog.showOpenDialog; + }, addedProgram); + await win.getByRole('button', { name: 'Add a browser…' }).click(); + const added = win.getByTestId('browser-setting').filter({ hasText: 'nightly' }); + await expect.poll(() => added.innerText()).toContain('added by you'); + + await win.getByTestId('browser-menu-button').click(); + const addedRow = win.getByTestId('browser-row').filter({ hasText: 'nightly' }); + await addedRow.click(); + await expect.poll(() => opened(addedFile), { timeout: 10_000 }).toBe(`${site.url}/`); + + await win.getByRole('button', { name: 'Remove nightly' }).click(); + await expect.poll(() => added.count()).toBe(0); + }); + + it("offers them in the website's own window too", async () => { + await rm(openedFile, { force: true }); + await win.getByRole('region', { name: 'Website preview' }).getByRole('button', { name: 'Open in its own window' }).click(); + const own = await waitFor(() => app.windows().find((p) => PAGE_WINDOW_URL.test(p.url()) && !p.isClosed())); + await own.waitForSelector('body[data-ready]'); + await own.getByTestId('browser-menu-button').click(); + await row(own).click(); + await expect.poll(() => opened(openedFile), { timeout: 10_000 }).toBe(`${site.url}/`); + // No settings there: the gear is the editor's. + await own.getByTestId('browser-menu-button').click(); + expect(await own.getByRole('button', { name: 'Browser settings' }).count()).toBe(0); + }); +}); diff --git a/test/unit/browsers.test.ts b/test/unit/browsers.test.ts new file mode 100644 index 0000000..4e5cee4 --- /dev/null +++ b/test/unit/browsers.test.ts @@ -0,0 +1,277 @@ +/** + * Other browsers: finding them the way each system lists them (Linux launchers and icon themes, macOS apps, the + * Windows registry), telling their engines apart, what the user added or hid, and opening a page in one. + */ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { getFileIcon: async () => ({ isEmpty: () => true }) }, + nativeImage: { + createFromBuffer: (bytes: Buffer) => ({ + isEmpty: () => bytes.length === 0, + resize: () => ({ toDataURL: () => 'data:image/png;base64,SMALL' }), + }), + }, +})); + +const { parseDesktopEntry } = await import('../../src/main/browsers/findBrowsers/linux/parseDesktopEntry'); +const { splitExec } = await import('../../src/main/browsers/findBrowsers/linux/splitExec'); +const { commandOf } = await import('../../src/main/browsers/findBrowsers/linux/commandOf'); +const { isBrowserEntry } = await import('../../src/main/browsers/findBrowsers/linux/isBrowserEntry'); +const { programOf } = await import('../../src/main/browsers/findBrowsers/linux/programOf'); +const { findLinuxBrowsers } = await import('../../src/main/browsers/findBrowsers/linux/findLinuxBrowsers'); +const { labelDuplicates } = await import('../../src/main/browsers/findBrowsers/linux/labelDuplicates'); +const { parseRegQuery } = await import('../../src/main/browsers/findBrowsers/windows/parseRegQuery'); +const { programOfCommand } = await import('../../src/main/browsers/findBrowsers/windows/programOfCommand'); +const { engineOf } = await import('../../src/main/browsers/engineOf'); +const { launchEnv } = await import('../../src/main/browsers/launchEnv'); +const { BrowserRegistry } = await import('../../src/main/browsers'); +const { BrowserStore } = await import('../../src/main/store/BrowserStore'); +type FoundBrowser = import('../../src/main/browsers').FoundBrowser; + +const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'console-editor-browsers-'))); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +/** An executable shell script at `path` (made with its folders). */ +function script(path: string, body: string): string { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, `#!/bin/sh\n${body}\n`); + chmodSync(path, 0o755); + return path; +} + +/** A launcher file (made with its folders). */ +function launcher(path: string, keys: Record): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, ['[Desktop Entry]', ...Object.entries(keys).map(([k, v]) => `${k}=${v}`), '', '[Desktop Action new-window]', 'Name=New Window', 'Exec=ignored'].join('\n')); +} + +describe('Linux launchers', () => { + it('reads the entry group only, without localized keys, and unescapes values', () => { + const keys = parseDesktopEntry('# comment\n[Desktop Entry]\nName=Fire\\sfox\nName[de]=Feuerfuchs\nExec=firefox %u\n\n[Desktop Action private]\nName=Private\nExec=firefox --private-window %u\n'); + expect(keys).toEqual({ Name: 'Fire fox', Exec: 'firefox %u' }); + }); + + it('splits Exec as the spec quotes it', () => { + expect(splitExec('"/opt/My Browser/browser" --flag "a \\"quoted\\" \\$word" %U')).toEqual(['/opt/My Browser/browser', '--flag', 'a "quoted" $word', '%U']); + expect(splitExec(' env A=1 firefox\t%u ')).toEqual(['env', 'A=1', 'firefox', '%u']); + expect(splitExec('browser ""')).toEqual(['browser', '']); + }); + + it('puts the address where the first URL field code was, drops the other codes, and keeps a literal percent', () => { + expect(commandOf(['chrome', '--profile', '%U', '%i', '%c'])).toEqual({ command: ['chrome', '--profile'], urlAt: 2 }); + expect(commandOf(['flatpak', 'run', 'org.mozilla.firefox', '@@u', '%u', '@@'])).toEqual({ command: ['flatpak', 'run', 'org.mozilla.firefox', '@@u', '@@'], urlAt: 4 }); + expect(commandOf(['browser', '--zoom=100%%'])).toEqual({ command: ['browser', '--zoom=100%'], urlAt: 2 }); + expect(commandOf(['a', '%u', '%U'])).toEqual({ command: ['a'], urlAt: 1 }); + }); + + it('knows the program past env and its assignments', () => { + expect(programOf(['env', 'BAMF=1', 'X_Y=2', '/snap/bin/firefox', '--new'])).toBe('/snap/bin/firefox'); + expect(programOf(['google-chrome-stable'])).toBe('google-chrome-stable'); + expect(programOf(['env'])).toBeNull(); + }); + + it('offers browsers: the category, or web links with HTML; not hidden entries, terminals or the app itself', () => { + const base = { Type: 'Application', Name: 'B', Exec: 'b %u' }; + expect(isBrowserEntry({ ...base, Categories: 'Network;WebBrowser;' }, 'b.desktop')).toBe(true); + expect(isBrowserEntry({ ...base, MimeType: 'text/html;x-scheme-handler/https;' }, 'b.desktop')).toBe(true); + expect(isBrowserEntry({ ...base, MimeType: 'x-scheme-handler/https;' }, 'b.desktop')).toBe(false); + expect(isBrowserEntry({ ...base, Categories: 'WebBrowser;', NoDisplay: 'true' }, 'b.desktop')).toBe(false); + expect(isBrowserEntry({ ...base, Categories: 'WebBrowser;', Terminal: 'true' }, 'b.desktop')).toBe(false); + expect(isBrowserEntry({ ...base, Categories: 'WebBrowser;' }, 'console-editor.desktop')).toBe(false); + expect(isBrowserEntry({ ...base, Type: 'Link', Categories: 'WebBrowser;' }, 'b.desktop')).toBe(false); + }); + + describe('finding them', () => { + const env = { ...process.env }; + const home = join(tmp, 'linux-home'); + const system = join(tmp, 'linux-system'); + const bin = join(tmp, 'linux-bin'); + + beforeEach(() => { + process.env.XDG_DATA_HOME = home; + process.env.XDG_DATA_DIRS = system; + script(join(bin, 'fake-chrome'), 'exit 0'); + script(join(bin, 'fake-gecko'), 'exit 0'); + launcher(join(system, 'applications/fake-chrome.desktop'), { Type: 'Application', Name: 'Fake Chrome', Exec: `${bin}/fake-chrome --flag %U`, Icon: 'fake-chrome', Categories: 'Network;WebBrowser;' }); + launcher(join(system, 'applications/vendor/fake-gecko.desktop'), { Type: 'Application', Name: 'Fake Firefox', Exec: `${bin}/fake-gecko %u`, Icon: `${system}/abs.svg`, MimeType: 'text/html;x-scheme-handler/http;' }); + // The user's entry of the same id replaces the system's, browser or not. + launcher(join(system, 'applications/replaced.desktop'), { Type: 'Application', Name: 'Replaced', Exec: `${bin}/fake-chrome`, Categories: 'WebBrowser;' }); + launcher(join(home, 'applications/replaced.desktop'), { Type: 'Application', Name: 'Replaced', Exec: `${bin}/fake-chrome`, NoDisplay: 'true', Categories: 'WebBrowser;' }); + // Uninstalled: its program is gone. + launcher(join(system, 'applications/gone.desktop'), { Type: 'Application', Name: 'Gone', Exec: `${bin}/nothing-here %u`, Categories: 'WebBrowser;' }); + launcher(join(system, 'applications/editor.desktop'), { Type: 'Application', Name: 'Editor', Exec: `${bin}/fake-chrome`, Categories: 'Development;' }); + mkdirSync(join(system, 'icons/hicolor/48x48/apps'), { recursive: true }); + mkdirSync(join(system, 'icons/hicolor/128x128/apps'), { recursive: true }); + writeFileSync(join(system, 'icons/hicolor/48x48/apps/fake-chrome.png'), 'small'); + writeFileSync(join(system, 'icons/hicolor/128x128/apps/fake-chrome.png'), 'large'); + writeFileSync(join(system, 'abs.svg'), ''); + }); + afterEach(() => { + process.env.XDG_DATA_HOME = env.XDG_DATA_HOME; + process.env.XDG_DATA_DIRS = env.XDG_DATA_DIRS; + if (env.XDG_DATA_HOME === undefined) delete process.env.XDG_DATA_HOME; + if (env.XDG_DATA_DIRS === undefined) delete process.env.XDG_DATA_DIRS; + }); + + it('lists the browsers whose program is there, with their engine, command and best icon', async () => { + // Flatpak's and Snap's exports are looked in too: only this test's launchers are compared. + const found = (await findLinuxBrowsers()).filter((b) => b.program?.startsWith(bin)); + expect(found).toEqual([ + { + id: 'desktop:fake-chrome.desktop', + name: 'Fake Chrome', + engine: 'chromium', + command: [`${bin}/fake-chrome`, '--flag'], + urlAt: 2, + iconFile: join(system, 'icons/hicolor/128x128/apps/fake-chrome.png'), + app: null, + program: `${bin}/fake-chrome`, + added: false, + }, + expect.objectContaining({ id: 'desktop:vendor-fake-gecko.desktop', name: 'Fake Firefox', engine: 'gecko', iconFile: join(system, 'abs.svg'), urlAt: 1 }), + ]); + }); + }); + + it('tells two browsers of one name apart by where they came from', () => { + const b = (name: string) => ({ id: name, name, engine: 'gecko', command: [], urlAt: 0, iconFile: null, app: null, program: null, added: false }) as FoundBrowser; + const named = labelDuplicates([ + { browser: b('Firefox'), path: '/usr/share/applications/firefox.desktop' }, + { browser: b('Firefox'), path: '/var/lib/snapd/desktop/applications/firefox_firefox.desktop' }, + { browser: b('Firefox'), path: '/var/lib/flatpak/exports/share/applications/org.mozilla.firefox.desktop' }, + { browser: b('Chromium'), path: '/var/lib/snapd/desktop/applications/chromium.desktop' }, + ]).map((x) => x.name); + expect(named).toEqual(['Firefox', 'Firefox (Snap)', 'Firefox (Flatpak)', 'Chromium']); + }); +}); + +describe('Windows registry', () => { + const output = [ + '', + 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Clients\\StartMenuInternet\\Google Chrome', + ' (Default) REG_SZ Google Chrome', + '', + 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Clients\\StartMenuInternet\\Google Chrome\\shell\\open\\command', + ' (Default) REG_SZ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"', + '', + 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Clients\\StartMenuInternet\\Firefox-308046B0AF4A39CB', + ' (Default) REG_SZ Firefox', + ' Empty REG_SZ', + '', + ].join('\r\n'); + + it('reads keys and values, case aside', () => { + const keys = parseRegQuery(output); + const chrome = keys.get('hkey_local_machine\\software\\clients\\startmenuinternet\\google chrome\\shell\\open\\command'); + expect(chrome?.values.get('(default)')).toBe('"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"'); + expect(keys.get('hkey_local_machine\\software\\clients\\startmenuinternet\\firefox-308046b0af4a39cb')?.values.get('empty')).toBe(''); + expect(keys.get('hkey_local_machine\\software\\clients\\startmenuinternet\\google chrome')?.path).toBe('HKEY_LOCAL_MACHINE\\SOFTWARE\\Clients\\StartMenuInternet\\Google Chrome'); + }); + + it("takes a command's program, quoted or not", () => { + expect(programOfCommand('"C:\\Program Files\\Mozilla Firefox\\firefox.exe" -osint -url "%1"')).toBe('C:\\Program Files\\Mozilla Firefox\\firefox.exe'); + expect(programOfCommand('C:\\Browsers\\brave.exe --flag')).toBe('C:\\Browsers\\brave.exe'); + expect(programOfCommand('"unterminated')).toBeNull(); + expect(programOfCommand(' ')).toBeNull(); + }); +}); + +describe('Engines', () => { + it('tells engines from names, and knows when it can’t', () => { + expect(engineOf(['org.mozilla.firefox.desktop'])).toBe('gecko'); + expect(engineOf(['/usr/bin/librewolf'])).toBe('gecko'); + expect(engineOf(['Zen Browser'])).toBe('gecko'); + expect(engineOf(['Safari Technology Preview'])).toBe('webkit'); + expect(engineOf(['org.gnome.Epiphany.desktop'])).toBe('webkit'); + expect(engineOf(['C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'.replaceAll('\\', '/')])).toBe('chromium'); + expect(engineOf(['Arc'])).toBe('chromium'); + expect(engineOf(['Archive Utility'])).toBe('unknown'); + expect(engineOf([null, 'Brave Browser'])).toBe('chromium'); + expect(engineOf(['Lynx'])).toBe('unknown'); + }); + + it('starts browsers without the libraries an AppImage set for the app', () => { + const env = { APPIMAGE: '/x.AppImage', LD_LIBRARY_PATH: '/tmp/.mount/usr/lib', HOME: '/home/u' }; + expect(launchEnv(env)).toEqual({ APPIMAGE: '/x.AppImage', HOME: '/home/u' }); + const plain = { LD_LIBRARY_PATH: '/opt/lib' }; + expect(launchEnv(plain)).toBe(plain); + }); +}); + +describe('Added and hidden browsers', () => { + it('keeps them across a restart, and reads a malformed or newer file as none', async () => { + const path = join(tmp, 'prefs', 'browsers.json'); + const store = new BrowserStore(path); + await store.load(); + await store.add({ id: 'added:0a1b2c3d', name: 'Nightly', path: '/opt/nightly/firefox', engine: 'gecko' }); + await store.setHidden('desktop:chromium.desktop', true); + await store.setHidden('added:0a1b2c3d', true); + await store.setHidden('added:0a1b2c3d', false); + + const again = new BrowserStore(path); + await again.load(); + expect(again.get()).toEqual({ added: [{ id: 'added:0a1b2c3d', name: 'Nightly', path: '/opt/nightly/firefox', engine: 'gecko' }], hidden: ['desktop:chromium.desktop'] }); + + await again.remove('added:0a1b2c3d'); + expect(again.get().added).toEqual([]); + + writeFileSync(path, JSON.stringify({ version: 2, added: [], hidden: ['x'] })); + await again.load(); + expect(again.get()).toEqual({ added: [], hidden: [] }); + writeFileSync(path, JSON.stringify({ version: 1, added: [{ id: 'nope', name: 'X', path: '/x', engine: 'gecko' }, { id: 'added:00000000', name: 'Y', path: '/y', engine: 'blink' }], hidden: [1, 'a', 'a'] })); + await again.load(); + expect(again.get()).toEqual({ added: [], hidden: ['a'] }); + }); +}); + +describe('The registry', () => { + const out = join(tmp, 'opened.txt'); + const fake = script(join(tmp, 'registry-bin', 'fake-browser'), `if [ "$1" = "--version" ]; then echo "Fake Browser 12.3.4"; exit 0; fi\necho "$@" > ${out}`); + const found: FoundBrowser = { id: 'desktop:fake.desktop', name: 'Fake', engine: 'chromium', command: [fake, '--new-window'], urlAt: 2, iconFile: null, app: null, program: fake, added: false }; + + async function registry() { + const prefs = new BrowserStore(join(mkdtempSync(join(tmp, 'registry-')), 'browsers.json')); + await prefs.load(); + const events: unknown[] = []; + const find = vi.fn(async () => [found]); + return { registry: new BrowserRegistry({ prefs, send: (e) => events.push(e), find }), events, find, prefs }; + } + + it('lists what it found, looks again only after a minute, and announces versions once read', async () => { + const { registry: r, events, find } = await registry(); + expect(await r.list()).toEqual([{ id: found.id, name: 'Fake', engine: 'chromium', version: null, icon: null, added: false, hidden: false }]); + await r.list(); + expect(find).toHaveBeenCalledTimes(1); + await expect.poll(() => events.length).toBe(1); + expect(events[0]).toEqual({ type: 'browsers-changed', browsers: [expect.objectContaining({ id: found.id, version: '12.3.4' })] }); + }); + + it('opens an http(s) address with the browser’s command, and nothing else', async () => { + const { registry: r } = await registry(); + await r.open(found.id, 'https://shop.test/cart?x=1'); + await expect.poll(() => readFileSync(out, 'utf8').trim()).toBe('--new-window https://shop.test/cart?x=1'); + await expect(r.open(found.id, 'file:///etc/passwd')).rejects.toThrow('Only http(s)'); + await expect(r.open('desktop:unknown.desktop', 'https://shop.test/')).rejects.toThrow('no longer there'); + }); + + it('adds a program, hides and removes it, and refuses what can’t be run', async () => { + const { registry: r, events } = await registry(); + const added = await r.add(fake); + expect(added).toEqual({ id: expect.stringMatching(/^added:[0-9a-f]{8}$/), name: 'fake-browser', engine: 'unknown', version: null, icon: null, added: true, hidden: false }); + await r.setHidden(added.id, true); + expect((await r.list()).find((b) => b.id === added.id)?.hidden).toBe(true); + await r.remove(added.id); + expect((await r.list()).map((b) => b.id)).toEqual([found.id]); + expect(events.filter((e) => (e as { type: string }).type === 'browsers-changed').length).toBeGreaterThanOrEqual(3); + await expect(r.remove(found.id)).rejects.toThrow('Only a browser you added'); + await expect(r.add(join(tmp, 'missing'))).rejects.toThrow("isn't there"); + const plain = join(tmp, 'not-executable'); + writeFileSync(plain, ''); + await expect(r.add(plain)).rejects.toThrow("can't be run"); + await expect(r.add(tmp)).rejects.toThrow("isn't a program"); + }); +}); From 62715760f2543e35fa6ed5a3d156999a3d76f1ff Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 20:40:46 +0000 Subject: [PATCH 03/19] Capture the page, and compare it with designs pixel by pixel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A menu at the toolbar's end keeps captures of the page with the workspace: what it shows, all of it, or one element picked in it (in any frame, cross-site ones too, placed through each frame's owner). Each opens as a tab to zoom into, with the colour of the pixel under the pointer. Designs come in from files, or dropped or pasted on the menu, their scale read from their name or width. Compare with… lays the page out at a design's width and captures it, then shows the two side by side, swiped, faded over each other, or as their difference: how much differs, and each area that does. --- src/main/PageController/PageController.ts | 7 +- .../WorkspaceController.ts | 8 +- src/main/engine/constants/cdp.ts | 7 + .../InspectorService/InspectorService.ts | 7 +- src/main/inspector/constants.ts | 3 + src/main/inspector/reading/ComponentReader.ts | 8 +- src/main/inspector/reading/elementBox.ts | 21 ++ src/main/inspector/reading/frameOffset.ts | 33 +++ src/main/ipc/registerIpc.ts | 4 +- src/main/ipc/registerShotIpc.ts | 60 +++++ src/main/ipc/types.ts | 5 + src/main/launch/createWindow.ts | 9 +- src/main/launch/openStores.ts | 6 +- src/main/launch/types.ts | 2 + src/main/shots/PageShots/PageShots.ts | 113 +++++++++ src/main/shots/PageShots/index.ts | 1 + src/main/shots/PageShots/types.ts | 10 + src/main/shots/capture/atWidth.ts | 20 ++ src/main/shots/capture/captureOverCdp.ts | 25 ++ src/main/shots/capture/clipOf.ts | 18 ++ src/main/shots/capture/index.ts | 3 + src/main/shots/capture/types.ts | 21 ++ src/main/shots/capture/windowMetrics.ts | 16 ++ src/main/shots/captureName.ts | 20 ++ src/main/shots/constants.ts | 38 +++ src/main/shots/copyShotImage.ts | 11 + src/main/shots/designScale.ts | 12 + src/main/shots/imageInfo/constants.ts | 14 ++ src/main/shots/imageInfo/imageInfo.ts | 24 ++ src/main/shots/imageInfo/index.ts | 2 + src/main/shots/imageInfo/jpegSize.ts | 19 ++ src/main/shots/imageInfo/startsWith.ts | 4 + src/main/shots/imageInfo/types.ts | 8 + src/main/shots/imageInfo/webpSize.ts | 18 ++ src/main/shots/importDesignFiles.ts | 20 ++ src/main/shots/index.ts | 4 + src/main/shots/makeThumbnail.ts | 11 + src/main/shots/readPngSize.ts | 7 + src/main/shots/registerShotProtocol.ts | 30 +++ src/main/shots/waitUntilShown.ts | 16 ++ src/main/store/ShotStore/ShotStore.ts | 147 ++++++++++++ src/main/store/ShotStore/cleanShotName.ts | 8 + src/main/store/ShotStore/constants.ts | 29 +++ src/main/store/ShotStore/index.ts | 2 + src/main/store/ShotStore/newShotId.ts | 11 + src/main/store/ShotStore/readShots.ts | 21 ++ src/main/store/ShotStore/removeShotFiles.ts | 9 + .../store/ShotStore/sanitizeStoredShot.ts | 39 ++++ src/main/store/ShotStore/shotFiles.ts | 8 + src/main/store/ShotStore/toShot.ts | 7 + src/main/store/ShotStore/types.ts | 23 ++ src/main/store/ShotStore/uniqueShotName.ts | 12 + src/main/store/ShotStore/writeShots.ts | 12 + src/preload/browsersApi.ts | 12 + src/preload/index.ts | 9 +- src/preload/shotsApi.ts | 21 ++ src/renderer/index.html | 2 +- .../src/app/model/bridge/appEventHandlers.ts | 3 + .../app/model/bridge/commands/activeSavers.ts | 2 + .../app/model/bridge/events/receivePick.ts | 9 +- .../app/model/bridge/events/showPicking.ts | 5 +- .../src/app/model/bridge/startBridge.ts | 2 + .../pageWindowEventHandlers.ts | 2 + .../startPageWindowBridge.ts | 2 + .../editor-tab/model/store/constants.ts | 2 +- .../editor-tab/model/store/pageDirtyChecks.ts | 2 + .../entities/editor-tab/model/store/types.ts | 6 +- src/renderer/src/entities/shot/index.ts | 3 + .../src/entities/shot/lib/constants.ts | 12 + src/renderer/src/entities/shot/lib/index.ts | 4 + .../src/entities/shot/lib/matchesShot.ts | 10 + .../src/entities/shot/lib/shotDetail.ts | 15 ++ src/renderer/src/entities/shot/lib/shotUrl.ts | 7 + src/renderer/src/entities/shot/lib/timeAgo.ts | 10 + .../src/entities/shot/lib/useMinute.ts | 20 ++ .../src/entities/shot/model/store/index.ts | 2 + .../src/entities/shot/model/store/types.ts | 8 + .../entities/shot/model/store/useShotStore.ts | 8 + .../src/entities/shot/ui/ShotThumb.tsx | 28 +++ .../src/features/shot/capture/index.ts | 1 + .../shot/capture/model/capturePick.ts | 5 + .../shot/capture/model/captureShot.ts | 21 ++ .../src/features/shot/capture/model/index.ts | 4 + .../shot/capture/model/pickToCapture.ts | 15 ++ .../shot/capture/model/pickingStarted.ts | 11 + .../shot/capture/model/takePickCapture.ts | 10 + .../src/features/shot/compare/index.ts | 1 + .../features/shot/compare/lib/constants.ts | 20 ++ .../src/features/shot/compare/lib/decodeAt.ts | 16 ++ .../features/shot/compare/lib/diff.worker.ts | 16 ++ .../features/shot/compare/lib/diffPixels.ts | 39 ++++ .../features/shot/compare/lib/regionsOf.ts | 35 +++ .../src/features/shot/compare/lib/types.ts | 19 ++ .../features/shot/compare/lib/workerTypes.ts | 18 ++ .../src/features/shot/compare/lib/yiqDelta.ts | 13 ++ .../shot/compare/model/compareWithPage.ts | 13 ++ .../features/shot/compare/model/constants.ts | 5 + .../src/features/shot/compare/model/index.ts | 4 + .../shot/compare/model/openCompare.ts | 8 + .../shot/compare/model/readForDiff.ts | 8 + .../src/features/shot/compare/model/types.ts | 16 ++ .../shot/compare/model/useImageDiff.ts | 48 ++++ .../src/features/shot/import-design/index.ts | 1 + .../import-design/model/addDesignFiles.ts | 16 ++ .../shot/import-design/model/imageFilesOf.ts | 7 + .../shot/import-design/model/importDesigns.ts | 13 ++ .../shot/import-design/model/index.ts | 3 + .../shot/import-design/model/reportImport.ts | 17 ++ .../src/features/shot/manage/index.ts | 1 + .../features/shot/manage/model/copyShot.ts | 14 ++ .../features/shot/manage/model/deleteShot.ts | 20 ++ .../src/features/shot/manage/model/index.ts | 4 + .../features/shot/manage/model/renameShot.ts | 15 ++ .../features/shot/manage/model/saveShotAs.ts | 14 ++ .../src/features/shot/open-shot/index.ts | 1 + .../shot/open-shot/model/constants.ts | 2 + .../features/shot/open-shot/model/index.ts | 3 + .../features/shot/open-shot/model/openShot.ts | 8 + .../shot/open-shot/model/receiveShots.ts | 20 ++ .../shot/open-shot/model/showShotById.ts | 8 + src/renderer/src/shared/config/icons.ts | 6 + .../command-palette/ui/AppCommandPalette.tsx | 31 +-- .../widgets/command-palette/ui/shotGroup.ts | 15 ++ .../widgets/command-palette/ui/shotItems.ts | 20 ++ .../command-palette/ui/workspaceGroup.ts | 25 ++ .../ui/ComparePage/CompareHeader.tsx | 42 ++++ .../ui/ComparePage/ComparePage.tsx | 44 ++++ .../ui/ComparePage/ComparePane.tsx | 22 ++ .../ui/ComparePage/DiffCanvas.tsx | 27 +++ .../editor-panel/ui/ComparePage/DiffStats.tsx | 43 ++++ .../ui/ComparePage/DifferenceView.tsx | 36 +++ .../ui/ComparePage/OffsetControls.tsx | 35 +++ .../ui/ComparePage/SideBySide.tsx | 18 ++ .../editor-panel/ui/ComparePage/Stacked.tsx | 50 ++++ .../editor-panel/ui/ComparePage/constants.ts | 18 ++ .../editor-panel/ui/ComparePage/cssSize.ts | 6 + .../ui/ComparePage/differingShare.ts | 7 + .../editor-panel/ui/ComparePage/index.ts | 1 + .../editor-panel/ui/ComparePage/sourceOf.ts | 8 + .../editor-panel/ui/ComparePage/types.ts | 14 ++ .../ui/ComponentPage/ComponentHeader.tsx | 3 + .../ui/EditorPanel/pageTabIcons.ts | 2 + .../editor-panel/ui/EditorPanel/pageViews.ts | 4 + .../editor-panel/ui/ShotPage/CompareMenu.tsx | 33 +++ .../editor-panel/ui/ShotPage/ScaleControl.tsx | 28 +++ .../editor-panel/ui/ShotPage/ShotActions.tsx | 17 ++ .../editor-panel/ui/ShotPage/ShotHeader.tsx | 35 +++ .../editor-panel/ui/ShotPage/ShotName.tsx | 35 +++ .../editor-panel/ui/ShotPage/ShotPage.tsx | 43 ++++ .../editor-panel/ui/ShotPage/ShotViewer.tsx | 68 ++++++ .../editor-panel/ui/ShotPage/ZoomControls.tsx | 34 +++ .../editor-panel/ui/ShotPage/constants.ts | 11 + .../widgets/editor-panel/ui/ShotPage/index.ts | 1 + .../widgets/editor-panel/ui/ShotPage/types.ts | 9 + .../editor-panel/ui/ShotPage/usePixelProbe.ts | 34 +++ .../ui/PagePreview/PreviewToolbar.tsx | 8 +- .../page-preview/ui/ShotsMenu/CaptureMenu.tsx | 28 +++ .../page-preview/ui/ShotsMenu/ShotRow.tsx | 45 ++++ .../page-preview/ui/ShotsMenu/ShotStack.tsx | 18 ++ .../page-preview/ui/ShotsMenu/ShotsMenu.tsx | 70 ++++++ .../ui/ShotsMenu/ShotsMenuButton.tsx | 63 +++++ .../page-preview/ui/ShotsMenu/constants.ts | 19 ++ .../page-preview/ui/ShotsMenu/index.ts | 1 + .../page-preview/ui/ShotsMenu/types.ts | 4 + src/shared/constants.ts | 11 +- src/shared/ipcChannels.ts | 15 ++ src/shared/types/api.ts | 3 +- src/shared/types/events.ts | 5 + src/shared/types/index.ts | 3 + src/shared/types/shots.ts | 46 ++++ src/shared/types/shotsApi.ts | 31 +++ test/e2e/designs.e2e.test.ts | 131 +++++++++++ test/e2e/shots.e2e.test.ts | 220 ++++++++++++++++++ test/helpers/decodePng.ts | 62 +++++ test/helpers/encodePng.ts | 38 +++ test/integration/shots.chromium.test.ts | 105 +++++++++ test/renderer/shots.test.ts | 171 ++++++++++++++ test/unit/shots.test.ts | 179 ++++++++++++++ 178 files changed, 3767 insertions(+), 46 deletions(-) create mode 100644 src/main/inspector/reading/elementBox.ts create mode 100644 src/main/inspector/reading/frameOffset.ts create mode 100644 src/main/ipc/registerShotIpc.ts create mode 100644 src/main/shots/PageShots/PageShots.ts create mode 100644 src/main/shots/PageShots/index.ts create mode 100644 src/main/shots/PageShots/types.ts create mode 100644 src/main/shots/capture/atWidth.ts create mode 100644 src/main/shots/capture/captureOverCdp.ts create mode 100644 src/main/shots/capture/clipOf.ts create mode 100644 src/main/shots/capture/index.ts create mode 100644 src/main/shots/capture/types.ts create mode 100644 src/main/shots/capture/windowMetrics.ts create mode 100644 src/main/shots/captureName.ts create mode 100644 src/main/shots/constants.ts create mode 100644 src/main/shots/copyShotImage.ts create mode 100644 src/main/shots/designScale.ts create mode 100644 src/main/shots/imageInfo/constants.ts create mode 100644 src/main/shots/imageInfo/imageInfo.ts create mode 100644 src/main/shots/imageInfo/index.ts create mode 100644 src/main/shots/imageInfo/jpegSize.ts create mode 100644 src/main/shots/imageInfo/startsWith.ts create mode 100644 src/main/shots/imageInfo/types.ts create mode 100644 src/main/shots/imageInfo/webpSize.ts create mode 100644 src/main/shots/importDesignFiles.ts create mode 100644 src/main/shots/index.ts create mode 100644 src/main/shots/makeThumbnail.ts create mode 100644 src/main/shots/readPngSize.ts create mode 100644 src/main/shots/registerShotProtocol.ts create mode 100644 src/main/shots/waitUntilShown.ts create mode 100644 src/main/store/ShotStore/ShotStore.ts create mode 100644 src/main/store/ShotStore/cleanShotName.ts create mode 100644 src/main/store/ShotStore/constants.ts create mode 100644 src/main/store/ShotStore/index.ts create mode 100644 src/main/store/ShotStore/newShotId.ts create mode 100644 src/main/store/ShotStore/readShots.ts create mode 100644 src/main/store/ShotStore/removeShotFiles.ts create mode 100644 src/main/store/ShotStore/sanitizeStoredShot.ts create mode 100644 src/main/store/ShotStore/shotFiles.ts create mode 100644 src/main/store/ShotStore/toShot.ts create mode 100644 src/main/store/ShotStore/types.ts create mode 100644 src/main/store/ShotStore/uniqueShotName.ts create mode 100644 src/main/store/ShotStore/writeShots.ts create mode 100644 src/preload/browsersApi.ts create mode 100644 src/preload/shotsApi.ts create mode 100644 src/renderer/src/entities/shot/index.ts create mode 100644 src/renderer/src/entities/shot/lib/constants.ts create mode 100644 src/renderer/src/entities/shot/lib/index.ts create mode 100644 src/renderer/src/entities/shot/lib/matchesShot.ts create mode 100644 src/renderer/src/entities/shot/lib/shotDetail.ts create mode 100644 src/renderer/src/entities/shot/lib/shotUrl.ts create mode 100644 src/renderer/src/entities/shot/lib/timeAgo.ts create mode 100644 src/renderer/src/entities/shot/lib/useMinute.ts create mode 100644 src/renderer/src/entities/shot/model/store/index.ts create mode 100644 src/renderer/src/entities/shot/model/store/types.ts create mode 100644 src/renderer/src/entities/shot/model/store/useShotStore.ts create mode 100644 src/renderer/src/entities/shot/ui/ShotThumb.tsx create mode 100644 src/renderer/src/features/shot/capture/index.ts create mode 100644 src/renderer/src/features/shot/capture/model/capturePick.ts create mode 100644 src/renderer/src/features/shot/capture/model/captureShot.ts create mode 100644 src/renderer/src/features/shot/capture/model/index.ts create mode 100644 src/renderer/src/features/shot/capture/model/pickToCapture.ts create mode 100644 src/renderer/src/features/shot/capture/model/pickingStarted.ts create mode 100644 src/renderer/src/features/shot/capture/model/takePickCapture.ts create mode 100644 src/renderer/src/features/shot/compare/index.ts create mode 100644 src/renderer/src/features/shot/compare/lib/constants.ts create mode 100644 src/renderer/src/features/shot/compare/lib/decodeAt.ts create mode 100644 src/renderer/src/features/shot/compare/lib/diff.worker.ts create mode 100644 src/renderer/src/features/shot/compare/lib/diffPixels.ts create mode 100644 src/renderer/src/features/shot/compare/lib/regionsOf.ts create mode 100644 src/renderer/src/features/shot/compare/lib/types.ts create mode 100644 src/renderer/src/features/shot/compare/lib/workerTypes.ts create mode 100644 src/renderer/src/features/shot/compare/lib/yiqDelta.ts create mode 100644 src/renderer/src/features/shot/compare/model/compareWithPage.ts create mode 100644 src/renderer/src/features/shot/compare/model/constants.ts create mode 100644 src/renderer/src/features/shot/compare/model/index.ts create mode 100644 src/renderer/src/features/shot/compare/model/openCompare.ts create mode 100644 src/renderer/src/features/shot/compare/model/readForDiff.ts create mode 100644 src/renderer/src/features/shot/compare/model/types.ts create mode 100644 src/renderer/src/features/shot/compare/model/useImageDiff.ts create mode 100644 src/renderer/src/features/shot/import-design/index.ts create mode 100644 src/renderer/src/features/shot/import-design/model/addDesignFiles.ts create mode 100644 src/renderer/src/features/shot/import-design/model/imageFilesOf.ts create mode 100644 src/renderer/src/features/shot/import-design/model/importDesigns.ts create mode 100644 src/renderer/src/features/shot/import-design/model/index.ts create mode 100644 src/renderer/src/features/shot/import-design/model/reportImport.ts create mode 100644 src/renderer/src/features/shot/manage/index.ts create mode 100644 src/renderer/src/features/shot/manage/model/copyShot.ts create mode 100644 src/renderer/src/features/shot/manage/model/deleteShot.ts create mode 100644 src/renderer/src/features/shot/manage/model/index.ts create mode 100644 src/renderer/src/features/shot/manage/model/renameShot.ts create mode 100644 src/renderer/src/features/shot/manage/model/saveShotAs.ts create mode 100644 src/renderer/src/features/shot/open-shot/index.ts create mode 100644 src/renderer/src/features/shot/open-shot/model/constants.ts create mode 100644 src/renderer/src/features/shot/open-shot/model/index.ts create mode 100644 src/renderer/src/features/shot/open-shot/model/openShot.ts create mode 100644 src/renderer/src/features/shot/open-shot/model/receiveShots.ts create mode 100644 src/renderer/src/features/shot/open-shot/model/showShotById.ts create mode 100644 src/renderer/src/widgets/command-palette/ui/shotGroup.ts create mode 100644 src/renderer/src/widgets/command-palette/ui/shotItems.ts create mode 100644 src/renderer/src/widgets/command-palette/ui/workspaceGroup.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/CompareHeader.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/ComparePage.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/ComparePane.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/DiffCanvas.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/DiffStats.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/DifferenceView.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/OffsetControls.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/SideBySide.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/Stacked.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/constants.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/cssSize.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/differingShare.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/index.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/sourceOf.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ComparePage/types.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/CompareMenu.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/ScaleControl.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotHeader.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotName.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotPage.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotViewer.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/ZoomControls.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/constants.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/index.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/types.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/ShotPage/usePixelProbe.ts create mode 100644 src/renderer/src/widgets/page-preview/ui/ShotsMenu/CaptureMenu.tsx create mode 100644 src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotRow.tsx create mode 100644 src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotStack.tsx create mode 100644 src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenu.tsx create mode 100644 src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenuButton.tsx create mode 100644 src/renderer/src/widgets/page-preview/ui/ShotsMenu/constants.ts create mode 100644 src/renderer/src/widgets/page-preview/ui/ShotsMenu/index.ts create mode 100644 src/renderer/src/widgets/page-preview/ui/ShotsMenu/types.ts create mode 100644 src/shared/types/shots.ts create mode 100644 src/shared/types/shotsApi.ts create mode 100644 test/e2e/designs.e2e.test.ts create mode 100644 test/e2e/shots.e2e.test.ts create mode 100644 test/helpers/decodePng.ts create mode 100644 test/helpers/encodePng.ts create mode 100644 test/integration/shots.chromium.test.ts create mode 100644 test/renderer/shots.test.ts create mode 100644 test/unit/shots.test.ts diff --git a/src/main/PageController/PageController.ts b/src/main/PageController/PageController.ts index 5e66e15..1b230e4 100644 --- a/src/main/PageController/PageController.ts +++ b/src/main/PageController/PageController.ts @@ -1,5 +1,6 @@ import type { BrowserWindow, Session, WebContentsView } from 'electron'; import type { PageState } from '../../shared/types'; +import type { CdpTransport } from '../engine/cdp'; import type { PageInterception } from '../engine/PageInterception'; import type { NetworkLog } from '../network'; import { PageWindow } from '../PageWindow'; @@ -29,6 +30,8 @@ export class PageController { /** The requests the page, its frames and their workers send. */ readonly network: NetworkLog; private readonly engine: PageInterception; + /** The page's own CDP session (captures go through it). */ + readonly cdp: CdpTransport; /** The site's session (cookies, logins): reads out of the page go through it, like its favicon and source maps. */ readonly siteSession: Session; private readonly loader: PageLoader; @@ -41,13 +44,13 @@ export class PageController { this.window = new PageWindow({ editor: win, view: this.view, store: windowStore, moved: () => this.pushState() }); const wc = this.view.webContents; - const transport = attachDebugger(wc, (reason) => { + this.cdp = attachDebugger(wc, (reason) => { // Settle everything the engine is waiting on; nothing can be sent any more. this.engine.detach(); this.deps.send({ type: 'error', message: `Interception stopped: debugger detached (${reason})` }); }); - ({ frames: this.frames, network: this.network, engine: this.engine } = wirePage(transport, { store, rules, settings, send, siteSession: this.siteSession, breakpoints })); + ({ frames: this.frames, network: this.network, engine: this.engine } = wirePage(this.cdp, { store, rules, settings, send, siteSession: this.siteSession, breakpoints })); this.loader = new PageLoader(wc, this.engine, () => this.pushState()); // A page's "Leave site?" guard would silently cancel reloads after a save, diff --git a/src/main/WorkspaceController/WorkspaceController.ts b/src/main/WorkspaceController/WorkspaceController.ts index c1afdc2..889ceb2 100644 --- a/src/main/WorkspaceController/WorkspaceController.ts +++ b/src/main/WorkspaceController/WorkspaceController.ts @@ -1,6 +1,7 @@ import type { WebContents } from 'electron'; import type { AppEvent, Workspace, WorkspacePatch } from '../../shared/types'; import type { PageController } from '../PageController'; +import type { PageShots } from '../shots'; import type { ActionStore } from '../store/ActionStore'; import type { OverrideStore } from '../store/OverrideStore'; import type { RuleStore } from '../store/RuleStore'; @@ -10,7 +11,7 @@ import { PageFollower } from './PageFollower'; /** * Workspaces: each has its own page, tabs (kept by the renderer through the - * session store), overrides, rules, actions and source maps loaded from files. + * session store), overrides, rules, actions, source maps loaded from files, and captures and designs. * Switching leaves the page, makes the other workspace's overrides and rules the * ones applied and its actions the ones listed, and loads its last page. */ @@ -27,6 +28,7 @@ export class WorkspaceController { private readonly actions: ActionStore, private readonly send: (event: AppEvent) => void, private readonly sourceMaps?: SourceMapFileStore, + private readonly shots?: PageShots, ) { this.follower = new PageFollower(page, session, send); } @@ -45,6 +47,7 @@ export class WorkspaceController { this.rules.setWorkspace(activeId); this.actions.setWorkspace(activeId); this.sourceMaps?.setWorkspace(activeId); + this.shots?.setWorkspace(activeId); } /** Follows the page: remembers where the active workspace is, and its site's icon. */ @@ -87,6 +90,7 @@ export class WorkspaceController { // start would hand its overrides and rules to another workspace. await this.actions.removeWorkspace(id as string); await this.sourceMaps?.removeWorkspace(id as string); + await this.shots?.removeWorkspace(id as string); await this.store.removeWorkspace(id as string); await this.rules.removeWorkspace(id as string); await this.session.remove(id); @@ -108,10 +112,12 @@ export class WorkspaceController { this.rules.setWorkspace(this.session.activeId); this.actions.setWorkspace(this.session.activeId); this.sourceMaps?.setWorkspace(this.session.activeId); + this.shots?.setWorkspace(this.session.activeId); // One pattern refresh reads both stores; the rules then only need their event. await this.page.overridesChanged(); await this.page.rulesChanged(false); this.send({ type: 'actions-changed', actions: this.actions.list() }); + this.shots?.changed(); this.pushState(); const { url } = this.session.get(); if (url) void this.page.navigate(url, { fresh: true }); diff --git a/src/main/engine/constants/cdp.ts b/src/main/engine/constants/cdp.ts index baafa29..ba72aba 100644 --- a/src/main/engine/constants/cdp.ts +++ b/src/main/engine/constants/cdp.ts @@ -14,6 +14,10 @@ export const CDP = { // Events requestPaused: 'Fetch.requestPaused', }, + Emulation: { + clearDeviceMetricsOverride: 'Emulation.clearDeviceMetricsOverride', + setDeviceMetricsOverride: 'Emulation.setDeviceMetricsOverride', + }, DOMDebugger: { getEventListeners: 'DOMDebugger.getEventListeners', }, @@ -27,6 +31,7 @@ export const CDP = { DOM: { describeNode: 'DOM.describeNode', enable: 'DOM.enable', + getBoxModel: 'DOM.getBoxModel', getDocument: 'DOM.getDocument', getFrameOwner: 'DOM.getFrameOwner', pushNodesByBackendIdsToFrontend: 'DOM.pushNodesByBackendIdsToFrontend', @@ -76,8 +81,10 @@ export const CDP = { }, Page: { addScriptToEvaluateOnNewDocument: 'Page.addScriptToEvaluateOnNewDocument', + captureScreenshot: 'Page.captureScreenshot', enable: 'Page.enable', getFrameTree: 'Page.getFrameTree', + getLayoutMetrics: 'Page.getLayoutMetrics', getResourceContent: 'Page.getResourceContent', removeScriptToEvaluateOnNewDocument: 'Page.removeScriptToEvaluateOnNewDocument', setBypassCSP: 'Page.setBypassCSP', diff --git a/src/main/inspector/InspectorService/InspectorService.ts b/src/main/inspector/InspectorService/InspectorService.ts index effb755..d69b96b 100644 --- a/src/main/inspector/InspectorService/InspectorService.ts +++ b/src/main/inspector/InspectorService/InspectorService.ts @@ -1,4 +1,4 @@ -import type { ComponentTreeLevel, FrameStack, InspectedComponent } from '../../../shared/types'; +import type { ComponentTreeLevel, FrameStack, InspectedComponent, Rect } from '../../../shared/types'; import type { SessionKey } from '../../console/ConsoleFrames'; import { dropSessions } from '../../console/dropSessions'; import type { CdpTransport } from '../../engine/cdp'; @@ -92,6 +92,11 @@ export class InspectorService implements SessionObserver { return this.reader.describe(pickId, depth); } + /** Where a pick's element is, in the top page's viewport (CSS pixels). */ + elementBox(pickId: unknown): Promise { + return this.reader.box(pickId); + } + setComponentState(pickId: unknown, depth: unknown, edit: unknown): Promise { return this.reader.setState(pickId, depth, edit); } diff --git a/src/main/inspector/constants.ts b/src/main/inspector/constants.ts index 986a5b0..5acede5 100644 --- a/src/main/inspector/constants.ts +++ b/src/main/inspector/constants.ts @@ -104,3 +104,6 @@ export const MAX_SELECTOR_LENGTH = 600; export const MAX_HOST_KIDS = 500; /** The object group a read's listeners are looked up in: the read's, with this after it. */ export const LISTENERS_GROUP_SUFFIX = '-listeners'; + +/** How many frames up an element's frame is looked for in its parents before it counts as gone (frames nest a few deep at most). */ +export const MAX_FRAME_HOPS = 16; diff --git a/src/main/inspector/reading/ComponentReader.ts b/src/main/inspector/reading/ComponentReader.ts index 84e9d34..c096c4e 100644 --- a/src/main/inspector/reading/ComponentReader.ts +++ b/src/main/inspector/reading/ComponentReader.ts @@ -1,4 +1,4 @@ -import type { InspectedComponent } from '../../../shared/types'; +import type { InspectedComponent, Rect } from '../../../shared/types'; import type { SessionKey } from '../../console/ConsoleFrames'; import type { RemoteObject } from '../../console/types'; import { CDP } from '../../engine/constants'; @@ -7,6 +7,7 @@ import { hideHighlights } from '../picking/hideHighlights'; import { showHighlight } from '../picking/showHighlight'; import type { InspectedSessions, Pick } from '../types'; import { angularRegistry } from './angularRegistry'; +import { elementBox } from './elementBox'; import { frameOfNode } from './frameOfNode'; import { inspectNode } from './inspectNode'; import { readComponent } from './readComponent'; @@ -92,6 +93,11 @@ export class ComponentReader { await hideHighlights(this.sessions, this.picking()); } + /** Where a pick's element is, in the top page's viewport (CSS pixels): what capturing it clips to. */ + box(pickId: unknown): Promise { + return elementBox(this.sessions, this.find(pickId, 0).pick); + } + /** A session went away: its picks' handles went with it. */ dropSession(sessionId: SessionKey): void { for (const pick of [...this.picks.values()]) { diff --git a/src/main/inspector/reading/elementBox.ts b/src/main/inspector/reading/elementBox.ts new file mode 100644 index 0000000..7861d69 --- /dev/null +++ b/src/main/inspector/reading/elementBox.ts @@ -0,0 +1,21 @@ +import type { Rect } from '../../../shared/types'; +import { CDP } from '../../engine/constants'; +import { PICK_GONE } from '../constants'; +import type { InspectedSessions, Pick } from '../types'; +import { frameOffset } from './frameOffset'; + +/** A quad's corners, as `DOM.getBoxModel` lists them: x1, y1, … x4, y4. */ +const QUAD_POINTS = [0, 2, 4, 6] as const; + +/** Where a picked element's border box is, in the top page's viewport (CSS pixels), whatever frame it is in. */ +export async function elementBox(sessions: InspectedSessions, pick: Pick): Promise { + const session = sessions.get(pick.sessionId); + if (!session) throw new Error(PICK_GONE); + const { model } = await session.transport.send<{ model: { border: number[] } }>(CDP.DOM.getBoxModel, { backendNodeId: pick.backendNodeId }).catch(() => { + throw new Error(PICK_GONE); + }); + const xs = QUAD_POINTS.map((i) => model.border[i]); + const ys = QUAD_POINTS.map((i) => model.border[i + 1]); + const offset = await frameOffset(sessions, pick.sessionId); + return { x: Math.min(...xs) + offset.x, y: Math.min(...ys) + offset.y, width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) }; +} diff --git a/src/main/inspector/reading/frameOffset.ts b/src/main/inspector/reading/frameOffset.ts new file mode 100644 index 0000000..ce2d2d0 --- /dev/null +++ b/src/main/inspector/reading/frameOffset.ts @@ -0,0 +1,33 @@ +import type { SessionKey } from '../../console/ConsoleFrames'; +import { CDP } from '../../engine/constants'; +import { MAX_FRAME_HOPS, PICK_GONE } from '../constants'; +import type { InspectedSessions } from '../types'; + +/** + * Where a session's viewport sits in the top page's, in CSS pixels: the page's own is at 0,0; an iframe another process + * runs is where its ``, + '/frame.html': '
', + }; + if (!pages[path]) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': 'text/html' }).end(pages[path]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + userData = await mkdtemp(join(tmpdir(), 'console-editor-e2e-shots-')); + await launch(); + const bar = win.getByTestId('address-bar'); + await bar.fill(`${origin}/shell.html`); + await bar.press('Enter'); + await waitFor(() => app.evaluate(({ webContents }, o) => webContents.getAllWebContents().some((wc) => wc.getURL() === `${o}/shell.html` && !wc.isLoading()), origin)); + }); + + afterAll(async () => { + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(userData, { recursive: true, force: true }); + }); + + it('captures what the page shows and the whole page from the shots menu, newest first', async () => { + await win.getByTestId('shots-menu-button').click(); + await menu().getByText('No captures yet.', { exact: false }).waitFor(); + await closeMenu(); + + await capture('What the page shows'); + await expect.poll(async () => (await shots()).length, { timeout: 15_000 }).toBe(1); + await capture('The whole page'); + await expect.poll(async () => (await shots()).length, { timeout: 15_000 }).toBe(2); + const [whole, shown] = await shots(); + expect(whole).toMatchObject({ kind: 'capture', area: 'page', height: 2400, browser: { id: 'app', name: 'Chromium' }, pageUrl: `${origin}/shell.html` }); + expect(whole.name).toMatch(/^127\.0\.0\.1-\d+-shell\.html-\d+-full\.png$/); + expect(shown.area).toBe('viewport'); + expect(await pixel(whole.id, 5, 2300)).toEqual([0, 255, 0]); + + await win.getByTestId('shots-menu-button').click(); + await expect.poll(() => menu().getByTestId('shot-row').count()).toBe(2); + expect(await menu().getByTestId('shot-row').first().getAttribute('data-shot-id')).toBe(whole.id); + await menu().getByRole('textbox', { name: 'Search captures and designs' }).fill('full'); + await expect.poll(() => menu().getByTestId('shot-row').count()).toBe(1); + await menu().getByRole('tab', { name: 'Designs' }).click(); + await menu().getByText('Nothing matches.').waitFor(); + await closeMenu(); + }); + + it("opens a capture's page: its size, the pixel under the pointer, and renaming it", async () => { + const [whole] = await shots(); + await win.getByTestId('shots-menu-button').click(); + await menu().locator(`[data-shot-id="${whole.id}"]`).click(); + const page = win.getByTestId('shot-page'); + await page.waitFor(); + await expect.poll(() => win.getByTestId('shot-footer').innerText()).toContain(`${whole.width} × ${whole.height} px`); + + await win.getByRole('button', { name: '100%' }).click(); + const image = win.getByTestId('shot-viewer').locator('img'); + const box = (await image.boundingBox())!; + await win.mouse.move(box.x + 5, box.y + 5); + await expect.poll(() => win.getByTestId('shot-pixel').innerText()).toContain('#0000ff'); + + await win.getByTestId('shot-name').click(); + await win.getByTestId('shot-name-input').fill('checkout.png'); + await win.getByTestId('shot-name-input').press('Enter'); + await expect.poll(async () => (await shots())[0].name).toBe('checkout.png'); + await expect.poll(() => win.getByRole('tab', { name: /checkout\.png/ }).count()).toBe(1); + }); + + it('captures an element picked in a cross-site frame, with its own pixels', async () => { + // Where the red box is in the page: the frame's box plus the box's in it. + const target = await waitFor(() => + app.evaluate(async ({ webContents }, o) => { + const page = webContents.getAllWebContents().find((wc) => wc.getURL() === `${o}/shell.html`); + const frame = page?.mainFrame.frames.find((f) => f.url.endsWith('/frame.html')); + const box = (target: { executeJavaScript(code: string): Promise } | undefined, selector: string) => + target?.executeJavaScript(`(() => { const r = document.querySelector(${JSON.stringify(selector)})?.getBoundingClientRect(); return r && { x: r.x, y: r.y }; })()`) as Promise<{ x: number; y: number } | undefined>; + const [outer, inner] = await Promise.all([box(page, 'iframe'), box(frame, '#red')]); + return outer && inner ? { x: Math.round(outer.x + inner.x + 20), y: Math.round(outer.y + inner.y + 20) } : undefined; + }, origin), + ); + await capture('An element…'); + await expect.poll(() => win.getByTestId('pick-element').getAttribute('aria-pressed')).toBe('true'); + await mouse('mouseMoved', target); + await mouse('mousePressed', target, { button: 'left', clickCount: 1 }); + await mouse('mouseReleased', target, { button: 'left', clickCount: 1 }); + await expect.poll(async () => (await shots()).length, { timeout: 15_000 }).toBe(3); + const element = (await shots())[0]; + expect(element).toMatchObject({ area: 'element', width: 120 * element.scale, height: 80 * element.scale }); + expect(element.name).toMatch(/-element\.png$/); + expect(await pixel(element.id, 1, 1)).toEqual([255, 0, 0]); + expect(await pixel(element.id, element.width - 2, element.height - 2)).toEqual([255, 0, 0]); + }); + + it('deletes a capture from its page, closing it', async () => { + const [element] = await shots(); + await win.getByTestId('shots-menu-button').click(); + await menu().locator(`[data-shot-id="${element.id}"]`).click(); + await win.getByTestId('shot-delete').click(); + await win.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click(); + await expect.poll(async () => (await shots()).map((s) => s.id)).not.toContain(element.id); + await expect.poll(() => win.getByRole('tab', { name: new RegExp(element.name.replaceAll('.', '\\.')) }).count()).toBe(0); + }); + + it('keeps captures per workspace, and across a restart', async () => { + const tiles = () => win.getByTestId('workspace-tile'); + await win.getByTestId('workspace-new').click(); + await expect.poll(() => tiles().nth(1).getAttribute('aria-current')).toBe('true'); + await expect.poll(async () => (await shots()).length).toBe(0); + await tiles().first().click(); + await expect.poll(() => tiles().first().getAttribute('aria-current')).toBe('true'); + await expect.poll(async () => (await shots()).length).toBe(2); + + await app.close(); + await launch(); + await expect.poll(async () => (await shots()).map((s) => s.name)).toEqual(['checkout.png', expect.stringMatching(/\.png$/)]); + }); + + it("opens a capture in the editor from the website's own window", async () => { + await win.getByRole('region', { name: 'Website preview' }).getByRole('button', { name: 'Open in its own window' }).click(); + const own = await waitFor(() => app.windows().find((p) => PAGE_WINDOW_URL.test(p.url()) && !p.isClosed())); + await own.waitForSelector('body[data-ready]'); + await own.getByTestId('shots-menu-button').click(); + await expect.poll(() => own.getByTestId('shot-row').count()).toBe(2); + // No deleting there: it would ask, and that window has no dialogs. + await own.getByTestId('shot-row').first().click(); + await expect.poll(() => win.getByTestId('shot-page').count(), { timeout: 10_000 }).toBe(1); + await expect.poll(() => win.getByTestId('shot-name').innerText()).toBe('checkout.png'); + }); +}); diff --git a/test/helpers/decodePng.ts b/test/helpers/decodePng.ts new file mode 100644 index 0000000..331ff7b --- /dev/null +++ b/test/helpers/decodePng.ts @@ -0,0 +1,62 @@ +/** + * Decodes an 8-bit, non-interlaced RGB or RGBA PNG (what Chromium's captures are) into RGBA pixels, so tests can + * look at what a capture shows. + */ +import { inflateSync } from 'node:zlib'; + +export interface DecodedPng { + width: number; + height: number; + /** The pixel at x, y as [r, g, b, a]. */ + at(x: number, y: number): [number, number, number, number]; +} + +const COLOR_TYPE_CHANNELS: Record = { 2: 3, 6: 4 }; + +export function decodePng(bytes: Buffer): DecodedPng { + let offset = 8; + let width = 0; + let height = 0; + let channels = 0; + const data: Buffer[] = []; + while (offset < bytes.length) { + const length = bytes.readUInt32BE(offset); + const type = bytes.toString('latin1', offset + 4, offset + 8); + const body = bytes.subarray(offset + 8, offset + 8 + length); + if (type === 'IHDR') { + width = body.readUInt32BE(0); + height = body.readUInt32BE(4); + if (body[8] !== 8 || body[12] !== 0) throw new Error('Only 8-bit, non-interlaced PNGs'); + channels = COLOR_TYPE_CHANNELS[body[9]]; + if (!channels) throw new Error(`PNG color type ${body[9]} isn't handled`); + } else if (type === 'IDAT') data.push(body); + offset += 12 + length; + } + const raw = inflateSync(Buffer.concat(data)); + const stride = width * channels; + const out = Buffer.alloc(height * stride); + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]; + for (let x = 0; x < stride; x++) { + const value = raw[y * (stride + 1) + 1 + x]; + const left = x >= channels ? out[y * stride + x - channels] : 0; + const up = y > 0 ? out[(y - 1) * stride + x] : 0; + const upLeft = x >= channels && y > 0 ? out[(y - 1) * stride + x - channels] : 0; + const paeth = () => { + const p = left + up - upLeft; + const [pa, pb, pc] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)]; + return pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + }; + const predictors = [0, left, up, (left + up) >> 1, paeth()]; + out[y * stride + x] = (value + predictors[filter]) & 0xff; + } + } + return { + width, + height, + at(x, y) { + const i = y * stride + x * channels; + return [out[i], out[i + 1], out[i + 2], channels === 4 ? out[i + 3] : 255]; + }, + }; +} diff --git a/test/helpers/encodePng.ts b/test/helpers/encodePng.ts new file mode 100644 index 0000000..a7abe69 --- /dev/null +++ b/test/helpers/encodePng.ts @@ -0,0 +1,38 @@ +/** Encodes RGB pixels as a PNG (8-bit, no filtering), for tests that need real image files. */ +import { deflateSync } from 'node:zlib'; + +/** CRC-32 of PNG chunks. */ +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc(bytes: Buffer): number { + let c = 0xffffffff; + for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const sum = Buffer.alloc(4); + sum.writeUInt32BE(crc(body)); + return Buffer.concat([length, body, sum]); +} + +/** A PNG of `width` × `height`, each pixel the colour `color(x, y)` gives. */ +export function encodePng(width: number, height: number, color: (x: number, y: number) => [number, number, number]): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 2; + const raw = Buffer.alloc((width * 3 + 1) * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) raw.set(color(x, y), y * (width * 3 + 1) + 1 + x * 3); + } + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', header), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); +} diff --git a/test/integration/shots.chromium.test.ts b/test/integration/shots.chromium.test.ts new file mode 100644 index 0000000..e237ffd --- /dev/null +++ b/test/integration/shots.chromium.test.ts @@ -0,0 +1,105 @@ +/** + * Captures against real Chromium: what the viewport shows, the whole page (past the viewport, which stays as it + * was), and an element picked in a cross-site frame (its own process and session), placed through its frame's + * owner and captured where it is on the page. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PageInterception } from '../../src/main/engine/PageInterception'; +import { FrameServices } from '../../src/main/PageController/FrameServices'; +import { captureOverCdp } from '../../src/main/shots/capture'; +import { DEFAULT_SETTINGS, type AppEvent, type InspectedComponent, type Settings } from '../../src/shared/types'; +import { chromiumAvailable, launchChromium, type ChromiumHarness } from '../helpers/chromium'; +import { decodePng } from '../helpers/decodePng'; + +async function waitFor(fn: () => T | undefined | Promise, timeout = 15_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 50)); + } +} + +describe.skipIf(!chromiumAvailable)('captures in Chromium', () => { + let server: Server; + let port: number; + let chrome: ChromiumHarness; + let page: Page; + let transport: Awaited>['transport']; + let services: FrameServices; + let interception: PageInterception; + const events: AppEvent[] = []; + const settings: Settings = { ...DEFAULT_SETTINGS }; + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const pages: Record = { + // A tall page: blue down to 1000 px, green below; a cross-site frame 300 px down, 100 in. + '/shell.html': `
`, + // In it, a red box 50 px in and 60 down. + '/frame.html': '
', + }; + const html = pages[path]; + if (!html) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': 'text/html' }).end(html); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + port = (server.address() as AddressInfo).port; + chrome = await launchChromium(); + ({ page, transport } = await chrome.newPage()); + await page.setViewportSize({ width: 800, height: 600 }); + services = new FrameServices(() => settings, (e) => events.push(e)); + interception = new PageInterception({ transport, getOverrides: () => [], getRules: () => [], getSettings: () => settings, emit: () => undefined, sessions: services }); + await interception.attach(); + await page.goto(`http://127.0.0.1:${port}/shell.html`); + await page.frameLocator('iframe').locator('#red').waitFor(); + }); + + afterAll(async () => { + interception?.detach(); + await chrome?.close(); + await new Promise((r) => server?.close(r)); + }); + + it('captures what the viewport shows', async () => { + const image = await captureOverCdp(transport, { area: 'viewport' }); + expect(image).toMatchObject({ width: 800, height: 600, scale: 1, viewport: { width: 800, height: 600 } }); + expect(decodePng(image.bytes).at(10, 10)).toEqual([0, 0, 255, 255]); + }); + + it('captures the whole page past the viewport, and leaves the viewport and scroll as they were', async () => { + await page.evaluate(() => window.scrollTo(0, 700)); + // The document's width: the viewport's less its scrollbar. + const documentWidth = await page.evaluate(() => document.documentElement.clientWidth); + const image = await captureOverCdp(transport, { area: 'page' }); + expect(image).toMatchObject({ width: documentWidth, height: 2400, scale: 1, viewport: { width: 800, height: 600 } }); + const png = decodePng(image.bytes); + expect(png.at(10, 10)).toEqual([0, 0, 255, 255]); + expect(png.at(10, 2300)).toEqual([0, 255, 0, 255]); + // (The scrollbar can hide afterwards here: a quirk of Playwright's viewport emulation, which the app doesn't use.) + expect(await page.evaluate(() => [window.innerWidth, window.innerHeight, window.scrollY])).toEqual([800, 600, 700]); + }); + + it('captures an element picked in a cross-site frame, where it is on the page', async () => { + await page.evaluate(() => window.scrollTo(0, 100)); + await services.inspector.startPicking(); + const red = page.frameLocator('iframe').locator('#red'); + const box = (await red.boundingBox())!; + await page.mouse.move(box.x + 10, box.y + 10); + await page.mouse.click(box.x + 10, box.y + 10); + const picked = await waitFor(() => events.find((e): e is Extract => e.type === 'inspect-picked')); + const { pickId } = picked.component as InspectedComponent; + + // 300 down and 100 in for the frame, 60 and 50 more for the box, 100 scrolled away: where the viewport shows it. + expect(await services.inspector.elementBox(pickId)).toEqual({ x: 150, y: 260, width: 120, height: 80 }); + const image = await captureOverCdp(transport, { area: 'element', box: await services.inspector.elementBox(pickId) }); + expect(image).toMatchObject({ width: 120, height: 80 }); + const png = decodePng(image.bytes); + for (const [x, y] of [[0, 0], [60, 40], [119, 79]]) expect(png.at(x, y)).toEqual([255, 0, 0, 255]); + }); +}); diff --git a/test/renderer/shots.test.ts b/test/renderer/shots.test.ts new file mode 100644 index 0000000..b0dbd14 --- /dev/null +++ b/test/renderer/shots.test.ts @@ -0,0 +1,171 @@ +/** + * Captures and designs in the renderer: a shot's line and age, the search, the pages that follow the list (closing + * a deleted shot's, renaming a renamed one's), and capturing an element picked for it (and not the next ordinary pick). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Shot } from '../../src/shared/types'; +import { useTabStore } from '@/entities/editor-tab'; +import { matchesShot, shotDetail, useShotStore } from '@/entities/shot'; +import { timeAgo } from '@/entities/shot/lib/timeAgo'; +import { captureShot, pickingStarted, pickToCapture, takePickCapture } from '@/features/shot/capture'; +import { openShot, receiveShots } from '@/features/shot/open-shot'; + +const api = vi.hoisted(() => ({ captureShot: vi.fn(), captureElementShot: vi.fn(), startPicking: vi.fn(async () => {}) })); +const toast = vi.hoisted(() => Object.assign(vi.fn(() => 'toast-1'), { dismiss: vi.fn(), update: vi.fn() })); + +vi.mock('@/shared/api', () => ({ api, onAppEvent: () => () => {}, errorMessage: (err: unknown) => (err as Error).message })); +vi.mock('@/shared/ui/toast', () => ({ toast })); +vi.mock('@/shared/monaco', () => ({ + monaco: { editor: { createModel: () => ({}) }, Uri: { from: () => ({}) } }, + languageFor: () => 'javascript', + setModelSchema: () => {}, + editorHasFocus: () => false, + dismissEditorWidgets: () => {}, + triggerInActiveEditor: () => {}, +})); + +const HOUR = 60 * 60 * 1000; + +const shot = (id: string, extra: Partial = {}): Shot => ({ + id, + kind: 'capture', + name: `${id}.png`, + width: 2880, + height: 1800, + scale: 2, + pageUrl: 'https://shop.test/cart', + browser: { id: 'app', name: 'Chromium', version: '152.0.7390.54' }, + viewport: { width: 1440, height: 900 }, + area: 'viewport', + group: null, + createdAt: 0, + updatedAt: 0, + ...extra, +}); + +beforeEach(() => { + vi.clearAllMocks(); + useTabStore.setState({ tabs: [], sources: [], pages: [], activeId: null }); + useShotStore.getState().setAll([]); +}); + +describe('A shot’s line', () => { + it('says how long ago, in its largest whole unit', () => { + expect(timeAgo(0, 30_000)).toBe('just now'); + expect(timeAgo(0, 60_000)).toBe('1 min ago'); + expect(timeAgo(0, 15 * HOUR + 59 * 60_000)).toBe('15 h ago'); + expect(timeAgo(0, 24 * HOUR)).toBe('1 day ago'); + expect(timeAgo(0, 50 * HOUR)).toBe('2 days ago'); + expect(timeAgo(10, 0)).toBe('just now'); + }); + + it("names a capture's kind, browser, viewport and age, and a design's width", () => { + expect(shotDetail(shot('a'), 15 * HOUR)).toBe('Capture · Chromium 152 · 1440 × 900 · 15 h ago'); + expect(shotDetail(shot('b', { kind: 'design', browser: null, viewport: null, width: 2880, scale: 2 }), 0)).toBe('Design · 1440 wide · just now'); + }); + + it('matches every word of a search in its name, address or browser', () => { + expect(matchesShot(shot('hero'), 'hero shop.test')).toBe(true); + expect(matchesShot(shot('hero'), 'chromium')).toBe(true); + expect(matchesShot(shot('hero'), 'hero firefox')).toBe(false); + expect(matchesShot(shot('hero'), '')).toBe(true); + }); +}); + +describe('Shot pages', () => { + it('close with their shot, and take its new name', () => { + openShot(shot('a')); + openShot(shot('b')); + expect(useTabStore.getState().pages.map((p) => p.title)).toEqual(['a.png', 'b.png']); + receiveShots([shot('b', { name: 'hero.png' })]); + expect(useTabStore.getState().pages.map((p) => [p.id, p.title])).toEqual([['shot:b', 'hero.png']]); + expect(useShotStore.getState().shots.map((s) => s.name)).toEqual(['hero.png']); + }); +}); + +describe('Capturing', () => { + it('captures the page and offers to open the capture; says why when it fails', async () => { + api.captureShot.mockResolvedValue(shot('a')); + const onOpen = vi.fn(); + expect(await captureShot('page', null, onOpen)).toEqual(shot('a')); + expect(api.captureShot).toHaveBeenCalledWith('page'); + const { action } = (toast.mock.calls.at(-1) as unknown as [{ title: string; action: { onClick(): void } }])[0]; + action.onClick(); + expect(onOpen).toHaveBeenCalledWith(shot('a')); + + api.captureShot.mockRejectedValue(new Error('Show the website to capture it')); + expect(await captureShot('viewport', null)).toBeNull(); + expect(toast).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Could not capture the page', description: 'Show the website to capture it', tone: 'danger' })); + }); + + it('captures the element picked for it, once, and not one picked after picking started again', async () => { + api.captureElementShot.mockResolvedValue(shot('el', { area: 'element' })); + await pickToCapture(); + expect(api.startPicking).toHaveBeenCalledTimes(1); + pickingStarted(); + takePickCapture('pick-1', openShot); + await vi.waitFor(() => expect(api.captureElementShot).toHaveBeenCalledWith('pick-1')); + takePickCapture('pick-2', openShot); + expect(api.captureElementShot).toHaveBeenCalledTimes(1); + + // Esc, then picking again for the inspector: that pick is an ordinary one. + await pickToCapture(); + pickingStarted(); + pickingStarted(); + takePickCapture('pick-3', openShot); + expect(api.captureElementShot).toHaveBeenCalledTimes(1); + }); +}); + +describe('Comparing', () => { + /** An image of one colour, with a box of another. */ + const image = (width: number, height: number, fill: number[], box?: { x: number; y: number; size: number; color: number[] }) => { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const inBox = box && x >= box.x && y >= box.y && x < box.x + box.size && y < box.y + box.size; + data.set(inBox ? box.color : fill, (y * width + x) * 4); + } + } + return { data, width, height }; + }; + const WHITE = [255, 255, 255, 255]; + const RED = [255, 0, 0, 255]; + + it('finds nothing between the same images, and ignores colours closer than the threshold', async () => { + const { diffPixels } = await import('@/features/shot/compare/lib/diffPixels'); + const a = image(40, 30, WHITE); + expect(diffPixels(a, image(40, 30, WHITE), { x: 0, y: 0 }, 0.1)).toMatchObject({ differing: 0, total: 1200, regions: [] }); + expect(diffPixels(a, image(40, 30, [250, 250, 250, 255]), { x: 0, y: 0 }, 0.1).differing).toBe(0); + }); + + it('counts the pixels that differ, marks them, and boxes each area', async () => { + const { diffPixels } = await import('@/features/shot/compare/lib/diffPixels'); + const a = image(100, 100, WHITE); + const b = image(100, 100, WHITE, { x: 60, y: 70, size: 10, color: RED }); + const result = diffPixels(a, b, { x: 0, y: 0 }, 0.1); + expect(result.differing).toBe(100); + expect(result.regions).toEqual([{ x: 48, y: 64, width: 32, height: 16 }]); + expect([...result.image.data.slice((75 * 100 + 65) * 4, (75 * 100 + 65) * 4 + 4)]).toEqual([255, 0, 64, 255]); + // A pixel the same in both is a faded grey. + expect(result.image.data[0]).toBeGreaterThan(240); + }); + + it('moves the second image by the offset, and counts what only one covers as differing', async () => { + const { diffPixels } = await import('@/features/shot/compare/lib/diffPixels'); + const a = image(50, 50, WHITE, { x: 10, y: 10, size: 5, color: RED }); + const b = image(50, 50, WHITE, { x: 7, y: 6, size: 5, color: RED }); + // The boxes line up once moved; only the pixels outside the 47 × 46 both cover differ. + expect(diffPixels(a, b, { x: 3, y: 4 }, 0.1)).toMatchObject({ differing: 53 * 54 - 47 * 46, total: 53 * 54 }); + const taller = diffPixels(image(10, 10, WHITE), image(10, 15, WHITE), { x: 0, y: 0 }, 0.1); + expect(taller).toMatchObject({ differing: 50, total: 150, regions: [{ x: 0, y: 0, width: 10, height: 15 }] }); + }); + + it('says how much differs as a share a person reads', async () => { + const { differingShare } = await import('@/widgets/editor-panel/ui/ComparePage/differingShare'); + expect(differingShare(0, 100)).toBe('0%'); + expect(differingShare(1, 1_000_000)).toBe('< 0.01%'); + expect(differingShare(402_500, 960_000)).toBe('41.9%'); + expect(differingShare(5, 1000)).toBe('0.50%'); + }); +}); diff --git a/test/unit/shots.test.ts b/test/unit/shots.test.ts new file mode 100644 index 0000000..65067fe --- /dev/null +++ b/test/unit/shots.test.ts @@ -0,0 +1,179 @@ +/** + * Captures and designs: keeping them with their workspace (names, limits, renames, scale, deleting a workspace's, + * a malformed file), what a capture is named and which part of the page it clips to, and a PNG's size. + */ +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { ShotStore, type NewShot } from '../../src/main/store/ShotStore'; +import { captureName } from '../../src/main/shots/captureName'; +import { clipOf } from '../../src/main/shots/capture/clipOf'; +import { readPngSize } from '../../src/main/shots/readPngSize'; + +const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'console-editor-shots-'))); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +/** A PNG header claiming `width` × `height` (enough for its size to be read). */ +function pngHeader(width: number, height: number): Buffer { + const bytes = Buffer.alloc(33); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes); + bytes.writeUInt32BE(13, 8); + bytes.write('IHDR', 12); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return bytes; +} + +const capture = (name: string, extra: Partial = {}): NewShot => ({ + kind: 'capture', + name, + width: 2880, + height: 1800, + scale: 2, + pageUrl: 'https://shop.test/cart', + browser: { id: 'app', name: 'Chromium', version: '152.0.1' }, + viewport: { width: 1440, height: 900 }, + area: 'viewport', + group: null, + bytes: pngHeader(2880, 1800), + ext: 'png', + thumb: Buffer.from('thumb'), + ...extra, +}); + +async function openStore(dir = mkdtempSync(join(tmp, 'store-'))): Promise { + const store = new ShotStore(dir); + await store.load(); + store.setWorkspace('ws1'); + return store; +} + +describe('ShotStore', () => { + it('keeps a shot with its image and thumbnail, lists the active workspace’s newest first, and keeps them across a restart', async () => { + const dir = mkdtempSync(join(tmp, 'store-')); + const store = await openStore(dir); + const first = await store.add(capture('shop.test-cart-1440.png')); + await new Promise((r) => setTimeout(r, 5)); + const second = await store.add(capture('shop.test-cart-1440.png', { area: 'page' })); + // A name already in the workspace gets a number. + expect(second.name).toBe('shop.test-cart-1440-2.png'); + expect(first).toEqual({ id: expect.stringMatching(/^[0-9a-f]{8}$/), kind: 'capture', name: 'shop.test-cart-1440.png', width: 2880, height: 1800, scale: 2, pageUrl: 'https://shop.test/cart', browser: { id: 'app', name: 'Chromium', version: '152.0.1' }, viewport: { width: 1440, height: 900 }, area: 'viewport', group: null, createdAt: expect.any(Number), updatedAt: expect.any(Number) }); + expect(store.list().map((s) => s.id)).toEqual([second.id, first.id]); + expect(readFileSync(store.paths(first.id).thumb, 'utf8')).toBe('thumb'); + expect((await store.read(first.id)).equals(pngHeader(2880, 1800))).toBe(true); + + store.setWorkspace('ws2'); + expect(store.list()).toEqual([]); + // Any workspace's shot is still reached by id. + expect(store.get(first.id).name).toBe('shop.test-cart-1440.png'); + await store.add(capture('shop.test-cart-1440.png')); + expect(store.list()[0].name).toBe('shop.test-cart-1440.png'); + + const again = new ShotStore(dir); + await again.load(); + again.setWorkspace('ws1'); + expect(again.list().map((s) => s.name)).toEqual(['shop.test-cart-1440-2.png', 'shop.test-cart-1440.png']); + }); + + it('renames, refusing an empty name, one another has, and cleaning what file systems refuse', async () => { + const store = await openStore(); + const a = await store.add(capture('a.png')); + const b = await store.add(capture('b.png')); + expect((await store.rename(a.id, ' home: hero/v2?.png ')).name).toBe('home- hero-v2-.png'); + await expect(store.rename(a.id, ' ')).rejects.toThrow('Give it a name'); + await expect(store.rename(a.id, 'b.png')).rejects.toThrow('Another has the name b.png'); + await expect(store.rename('00000000', 'c.png')).rejects.toThrow('no longer exists'); + expect(store.get(b.id).name).toBe('b.png'); + expect((await store.setScale(a.id, 3)).scale).toBe(3); + await expect(store.setScale(a.id, 0)).rejects.toThrow('Invalid scale'); + }); + + it('deletes a shot with its files, and a workspace’s shots with theirs', async () => { + const store = await openStore(); + const a = await store.add(capture('a.png')); + const { image, thumb } = store.paths(a.id); + await store.remove(a.id); + expect(existsSync(image) || existsSync(thumb)).toBe(false); + expect(store.list()).toEqual([]); + + const b = await store.add(capture('b.png')); + store.setWorkspace('other'); + const c = await store.add(capture('c.png')); + const bFiles = store.paths(b.id); + await store.removeWorkspace('ws1'); + expect(existsSync(bFiles.image)).toBe(false); + expect(store.get(c.id).name).toBe('c.png'); + expect(() => store.get(b.id)).toThrow('no longer exists'); + }); + + it('refuses a file too large, and more shots than a workspace keeps', async () => { + const store = await openStore(); + await expect(store.add(capture('big.png', { bytes: Buffer.alloc(50 * 1024 * 1024 + 1) }))).rejects.toThrow('at most 50 MB'); + await Promise.all(Array.from({ length: 500 }, (_, i) => store.add(capture(`${i}.png`, { thumb: null })))); + await expect(store.add(capture('501.png'))).rejects.toThrow('at most 500'); + }); + + it('reads a malformed file or record as none, and a newer file as empty', async () => { + const dir = mkdtempSync(join(tmp, 'store-')); + const good = { id: '0a1b2c3d', workspaceId: 'ws1', kind: 'design', name: 'hero.png', width: 2880, height: 4000, scale: 2, ext: 'png', createdAt: 1, updatedAt: 1, pageUrl: null, browser: null, viewport: null, area: null, group: null }; + writeFileSync(join(dir, 'shots.json'), JSON.stringify({ version: 1, shots: [good, { ...good, id: 'bad' }, { ...good, id: '11111111', width: -1 }, { ...good, id: '22222222', kind: 'photo' }, good] })); + const store = await openStore(dir); + expect(store.list()).toEqual([{ id: '0a1b2c3d', kind: 'design', name: 'hero.png', width: 2880, height: 4000, scale: 2, createdAt: 1, updatedAt: 1, pageUrl: null, browser: null, viewport: null, area: null, group: null }]); + writeFileSync(join(dir, 'shots.json'), JSON.stringify({ version: 2, shots: [good] })); + await store.load(); + expect(store.list()).toEqual([]); + }); +}); + +describe('Capturing', () => { + it('names a capture after its page, width and what it covers', () => { + expect(captureName('https://shop.test/cart/items?x=1', 1440, 'page')).toBe('shop.test-cart-items-1440-full.png'); + expect(captureName('http://127.0.0.1:5173/', 390.4, 'viewport')).toBe('127.0.0.1-5173-390.png'); + expect(captureName('about:blank', 800, 'element')).toBe('page-800-element.png'); + expect(captureName('not a url', 800, 'viewport')).toBe('page-800.png'); + }); + + it('clips to the whole page (down to the texture limit) or to an element moved by the scroll, inside the page', () => { + const metrics = { cssLayoutViewport: { pageX: 0, pageY: 500, clientWidth: 1440, clientHeight: 900 }, cssContentSize: { width: 1440, height: 20_000 } }; + expect(clipOf({ area: 'viewport' }, metrics, 8192)).toBeNull(); + expect(clipOf({ area: 'page' }, metrics, 8192)).toEqual({ x: 0, y: 0, width: 1440, height: 8192 }); + expect(clipOf({ area: 'element', box: { x: 100, y: 50, width: 200, height: 80 } }, metrics, 8192)).toEqual({ x: 100, y: 550, width: 200, height: 80 }); + // Partly off the page's left edge: what is on the page. + expect(clipOf({ area: 'element', box: { x: -40, y: 0, width: 100, height: 10 } }, metrics, 8192)).toEqual({ x: 0, y: 500, width: 60, height: 10 }); + expect(() => clipOf({ area: 'element', box: { x: 10, y: 10, width: 0, height: 10 } }, metrics, 8192)).toThrow('no size'); + }); + + it("reads a PNG's size from its header", () => { + expect(readPngSize(pngHeader(1024, 7))).toEqual({ width: 1024, height: 7 }); + expect(() => readPngSize(Buffer.alloc(3))).toThrow('empty'); + }); +}); + +describe('Designs', () => { + it("reads a PNG's, JPEG's and WebP's type and size from their bytes, and nothing else's", async () => { + const { imageInfo } = await import('../../src/main/shots/imageInfo'); + expect(imageInfo(pngHeader(2880, 5000))).toEqual({ ext: 'png', width: 2880, height: 5000 }); + // A JPEG: SOI, an APP0 segment, then a baseline frame header (height 1080, width 1920). + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x04, 0x38, 0x07, 0x80, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(imageInfo(jpeg)).toEqual({ ext: 'jpg', width: 1920, height: 1080 }); + const riff = (chunk: string, data: number[]) => Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP'), Buffer.from(chunk, 'latin1'), Buffer.alloc(4), Buffer.from(data), Buffer.alloc(10)]); + // Lossy: a frame tag (3 bytes), a start code (3), then 14-bit width and height, little-endian. + expect(imageInfo(riff('VP8 ', [0, 0, 0, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01]))).toEqual({ ext: 'webp', width: 640, height: 480 }); + // Lossless: a signature byte, then width − 1 and height − 1 in 14 bits each. + const bits = (640 - 1) | ((480 - 1) << 14); + expect(imageInfo(riff('VP8L', [0x2f, bits & 0xff, (bits >> 8) & 0xff, (bits >> 16) & 0xff, (bits >> 24) & 0xff]))).toEqual({ ext: 'webp', width: 640, height: 480 }); + // Extended: flags (4 bytes), then 24-bit width − 1 and height − 1. + expect(imageInfo(riff('VP8X', [0, 0, 0, 0, 0x7f, 0x02, 0x00, 0xdf, 0x01, 0x00]))).toEqual({ ext: 'webp', width: 640, height: 480 }); + expect(imageInfo(Buffer.from(''))).toBeNull(); + expect(imageInfo(Buffer.from([0x89, 0x50, 0x4e]))).toBeNull(); + }); + + it("takes a design's scale from its name, else its width", async () => { + const { designScale } = await import('../../src/main/shots/designScale'); + expect(designScale('hero@2x.png', 800)).toBe(2); + expect(designScale('Hero @3X.jpg', 800)).toBe(3); + expect(designScale('hero.png', 2880)).toBe(2); + expect(designScale('hero.png', 1440)).toBe(1); + }); +}); From 9c7cc546fcf22e4b6a0b0b449000282e614c6b42 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 20:46:59 +0000 Subject: [PATCH 04/19] Mock the shots and browsers the bridge now loads at start in its tests --- test/renderer/bridge-start.test.ts | 4 ++++ test/renderer/pageTabs.test.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/test/renderer/bridge-start.test.ts b/test/renderer/bridge-start.test.ts index b4be0f6..0d881d6 100644 --- a/test/renderer/bridge-start.test.ts +++ b/test/renderer/bridge-start.test.ts @@ -25,6 +25,8 @@ const api = vi.hoisted(() => ({ listResources: vi.fn(), listNetworkRequests: vi.fn(), listHeldRequests: vi.fn(), + listShots: vi.fn(), + listBrowsers: vi.fn(), getPageState: vi.fn(), sessionFlushed: vi.fn(), })); @@ -114,6 +116,8 @@ describe('start bridge', () => { api.listResources.mockResolvedValue([]); api.listNetworkRequests.mockResolvedValue([]); api.listHeldRequests.mockResolvedValue([]); + api.listShots.mockResolvedValue([]); + api.listBrowsers.mockResolvedValue([]); api.getPageState.mockResolvedValue(PAGE); }); afterEach(() => { diff --git a/test/renderer/pageTabs.test.ts b/test/renderer/pageTabs.test.ts index 4e525a1..246de59 100644 --- a/test/renderer/pageTabs.test.ts +++ b/test/renderer/pageTabs.test.ts @@ -92,7 +92,7 @@ describe('page tabs', () => { expect(isPageDirty(newRulePage('n1', EDITED))).toBe(true); expect(isPageDirty(STACK)).toBe(false); expect(isPageDirty(COMPONENT)).toBe(false); - expect(PAGE_SCOPES).toEqual({ 'whats-new': 'app', stack: 'app', rule: 'workspace', 'new-rule': 'workspace', component: 'workspace' }); + expect(PAGE_SCOPES).toEqual({ 'whats-new': 'app', stack: 'app', rule: 'workspace', 'new-rule': 'workspace', component: 'workspace', shot: 'workspace', compare: 'workspace' }); }); it("closes the workspace's rule and new-rule pages with its tabs; What's New and the Page stack stay", () => { From a76d0c28870d2712667b9843384becf983293d7a Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 20:56:33 +0000 Subject: [PATCH 05/19] Lay a design over the live page to check it pixel by pixel "Put over the page" draws a design or capture over the website's top frame: on a canvas from an isolated world, so a strict CSP can't refuse it, kept after reloads, and out of the pointer's way. A bar under the preview's toolbar sets how see-through it is, blends it as a difference, moves it (the arrow keys nudge it), pins it to the page or the viewport, and lays the page out at the design's width, scaled to fit the preview. Captures leave it out. --- src/main/engine/constants/cdp.ts | 1 + src/main/ipc/registerIpc.ts | 8 +- src/main/ipc/registerOverlayIpc.ts | 11 ++ src/main/overlay/DesignOverlay.ts | 98 +++++++++++++++++ src/main/overlay/PageOverlay.ts | 62 +++++++++++ src/main/overlay/constants.ts | 21 ++++ src/main/overlay/fitToWidth.ts | 15 +++ src/main/overlay/index.ts | 1 + src/main/overlay/overlayCall.ts | 6 ++ src/main/overlay/overlaySource.ts | 80 ++++++++++++++ src/main/overlay/overlayStyle.ts | 30 ++++++ src/main/overlay/types.ts | 9 ++ src/main/shots/PageShots/PageShots.ts | 17 ++- src/preload/index.ts | 2 + src/preload/overlayApi.ts | 11 ++ .../src/app/model/bridge/appEventHandlers.ts | 2 + .../src/app/model/bridge/startBridge.ts | 2 + .../pageWindowEventHandlers.ts | 1 + .../startPageWindowBridge.ts | 1 + .../src/entities/shot/model/store/types.ts | 5 +- .../entities/shot/model/store/useShotStore.ts | 4 +- .../src/features/shot/overlay/index.ts | 1 + .../src/features/shot/overlay/model/index.ts | 3 + .../shot/overlay/model/overlayUpdates.ts | 4 + .../shot/overlay/model/removeOverlay.ts | 11 ++ .../shot/overlay/model/showOverlay.ts | 12 +++ .../shot/overlay/model/updateOverlay.ts | 30 ++++++ src/renderer/src/shared/config/icons.ts | 6 ++ .../editor-panel/ui/ShotPage/ShotActions.tsx | 4 +- .../ui/OverlayBar/OffsetField.tsx | 23 ++++ .../page-preview/ui/OverlayBar/OverlayBar.tsx | 53 +++++++++ .../page-preview/ui/OverlayBar/constants.ts | 15 +++ .../page-preview/ui/OverlayBar/index.ts | 1 + .../ui/PagePreview/PagePreview.tsx | 2 + .../page-preview/ui/ShotsMenu/ShotRow.tsx | 2 + src/shared/constants.ts | 2 +- src/shared/ipcChannels.ts | 5 + src/shared/overlay/index.ts | 1 + src/shared/overlay/overlaySettingsSchema.ts | 19 ++++ src/shared/types/api.ts | 3 +- src/shared/types/events.ts | 3 + src/shared/types/index.ts | 2 + src/shared/types/overlay.ts | 27 +++++ src/shared/types/overlayApi.ts | 11 ++ test/e2e/designs.e2e.test.ts | 52 ++++++++- test/integration/overlay.chromium.test.ts | 102 ++++++++++++++++++ test/renderer/bridge-start.test.ts | 2 + 47 files changed, 772 insertions(+), 11 deletions(-) create mode 100644 src/main/ipc/registerOverlayIpc.ts create mode 100644 src/main/overlay/DesignOverlay.ts create mode 100644 src/main/overlay/PageOverlay.ts create mode 100644 src/main/overlay/constants.ts create mode 100644 src/main/overlay/fitToWidth.ts create mode 100644 src/main/overlay/index.ts create mode 100644 src/main/overlay/overlayCall.ts create mode 100644 src/main/overlay/overlaySource.ts create mode 100644 src/main/overlay/overlayStyle.ts create mode 100644 src/main/overlay/types.ts create mode 100644 src/preload/overlayApi.ts create mode 100644 src/renderer/src/features/shot/overlay/index.ts create mode 100644 src/renderer/src/features/shot/overlay/model/index.ts create mode 100644 src/renderer/src/features/shot/overlay/model/overlayUpdates.ts create mode 100644 src/renderer/src/features/shot/overlay/model/removeOverlay.ts create mode 100644 src/renderer/src/features/shot/overlay/model/showOverlay.ts create mode 100644 src/renderer/src/features/shot/overlay/model/updateOverlay.ts create mode 100644 src/renderer/src/widgets/page-preview/ui/OverlayBar/OffsetField.tsx create mode 100644 src/renderer/src/widgets/page-preview/ui/OverlayBar/OverlayBar.tsx create mode 100644 src/renderer/src/widgets/page-preview/ui/OverlayBar/constants.ts create mode 100644 src/renderer/src/widgets/page-preview/ui/OverlayBar/index.ts create mode 100644 src/shared/overlay/index.ts create mode 100644 src/shared/overlay/overlaySettingsSchema.ts create mode 100644 src/shared/types/overlay.ts create mode 100644 src/shared/types/overlayApi.ts create mode 100644 test/integration/overlay.chromium.test.ts diff --git a/src/main/engine/constants/cdp.ts b/src/main/engine/constants/cdp.ts index ba72aba..31c645b 100644 --- a/src/main/engine/constants/cdp.ts +++ b/src/main/engine/constants/cdp.ts @@ -82,6 +82,7 @@ export const CDP = { Page: { addScriptToEvaluateOnNewDocument: 'Page.addScriptToEvaluateOnNewDocument', captureScreenshot: 'Page.captureScreenshot', + createIsolatedWorld: 'Page.createIsolatedWorld', enable: 'Page.enable', getFrameTree: 'Page.getFrameTree', getLayoutMetrics: 'Page.getLayoutMetrics', diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 2c5ce04..b950883 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -10,6 +10,7 @@ import { registerConsoleIpc } from './registerConsoleIpc'; import { registerHarIpc } from './registerHarIpc'; import { registerInspectorIpc } from './registerInspectorIpc'; import { registerNetworkIpc } from './registerNetworkIpc'; +import { registerOverlayIpc } from './registerOverlayIpc'; import { registerOverrideIpc } from './registerOverrideIpc'; import { registerOverridesFileIpc } from './registerOverridesFileIpc'; import { registerRuleIpc } from './registerRuleIpc'; @@ -54,7 +55,11 @@ export function registerIpc({ win, page, store, rules, settings, session, action handle(IPC_CHANNEL.detachPage, () => page.window.detach()); handlePage(IPC_CHANNEL.attachPage, () => page.window.attach()); // Only the window showing the page places it: the other one's reports (a panel going away) are stale. - ipcMain.on(IPC_CHANNEL.setPageBounds, (event, rect: Rect) => page.window.place(event.sender, rect)); + ipcMain.on(IPC_CHANNEL.setPageBounds, (event, rect: Rect) => { + page.window.place(event.sender, rect); + // A design's width scales the page to the view: to its new size. + shots.overlay.fit(); + }); handle(IPC_CHANNEL.listResources, () => page.listResources()); handle(IPC_CHANNEL.getResourceContent, (url: unknown) => { @@ -84,6 +89,7 @@ export function registerIpc({ win, page, store, rules, settings, session, action registerOverridesFileIpc(handle, { win, page, store, rules }); registerBrowserIpc(handle, handlePage, { win, browsers }); registerShotIpc(handle, handlePage, { win, shots, store: shotStore, send }); + registerOverlayIpc(handlePage, shots.overlay); handle(IPC_CHANNEL.getSession, () => session.get()); handle(IPC_CHANNEL.saveSessionTabs, (workspaceId: unknown, tabs: unknown, activeTabId: unknown) => session.setTabs(workspaceId, tabs, activeTabId)); diff --git a/src/main/ipc/registerOverlayIpc.ts b/src/main/ipc/registerOverlayIpc.ts new file mode 100644 index 0000000..189499c --- /dev/null +++ b/src/main/ipc/registerOverlayIpc.ts @@ -0,0 +1,11 @@ +import { IPC_CHANNEL } from '../../shared/ipcChannels'; +import type { DesignOverlay } from '../overlay'; +import type { IpcHandle } from './types'; + +/** The design overlay's channels, for both windows' UIs: its bar is under the preview's toolbar. */ +export function registerOverlayIpc(handlePage: IpcHandle, overlay: DesignOverlay): void { + handlePage(IPC_CHANNEL.getOverlay, () => overlay.get()); + handlePage(IPC_CHANNEL.showOverlay, (shotId: unknown) => overlay.show(shotId)); + handlePage(IPC_CHANNEL.updateOverlay, (patch: unknown) => overlay.update(patch)); + handlePage(IPC_CHANNEL.removeOverlay, () => overlay.remove()); +} diff --git a/src/main/overlay/DesignOverlay.ts b/src/main/overlay/DesignOverlay.ts new file mode 100644 index 0000000..7083566 --- /dev/null +++ b/src/main/overlay/DesignOverlay.ts @@ -0,0 +1,98 @@ +import type { OverlayState } from '../../shared/types'; +import { overlaySettingsSchema } from '../../shared/overlay'; +import { parseInput } from '../store/parseInput'; +import { DEFAULT_OVERLAY_SETTINGS } from './constants'; +import { fitToWidth } from './fitToWidth'; +import { PageOverlay } from './PageOverlay'; +import type { DesignOverlayDeps } from './types'; + +/** A patch of the settings: any of them, each checked as a whole setting is. */ +const PATCH_SCHEMA = overlaySettingsSchema.partial(); + +/** + * The design laid over the app's page: shown in its top frame (after reloads too), styled as set, the page laid out at + * the design's width while that is on; every change announced (`overlay-changed`) to both windows. It is taken off + * with its shot, and when the workspace changes. + */ +export class DesignOverlay { + private state: OverlayState | null = null; + private readonly overlay: PageOverlay; + /** The width the page is laid out at and the view's size then ('' at its own width): unchanged, it isn't laid out again. */ + private fitted = ''; + + constructor(private readonly deps: DesignOverlayDeps) { + this.overlay = new PageOverlay(deps.page.cdp); + } + + get(): OverlayState | null { + return this.state; + } + + /** Lays a shot over the page, keeping the settings of the one it replaces. */ + async show(shotId: unknown): Promise { + const shot = this.deps.store.get(shotId); + const bytes = await this.deps.store.read(shotId); + const state: OverlayState = { shotId: shot.id, name: shot.name, width: Math.round(shot.width / shot.scale), height: Math.round(shot.height / shot.scale), settings: this.state?.settings ?? DEFAULT_OVERLAY_SETTINGS }; + await this.overlay.show(bytes.toString('base64'), state.settings, state.width, state.height); + this.state = state; + this.fit(); + this.announce(); + return state; + } + + async update(patch: unknown): Promise { + const current = this.state; + if (!current) throw new Error('No design is over the page'); + const state = { ...current, settings: { ...current.settings, ...parseInput(PATCH_SCHEMA, patch, 'overlay settings') } }; + this.state = state; + await this.overlay.restyle(state.settings, state.width, state.height); + this.fit(); + this.announce(); + return state; + } + + async remove(): Promise { + if (!this.state) return; + this.state = null; + this.fit(); + this.announce(); + await this.overlay.remove(); + } + + /** A shot was deleted: the overlay goes with it. */ + shotRemoved(id: string): Promise { + return this.state?.shotId === id ? this.remove() : Promise.resolve(); + } + + /** Lays the page out at the design's width while that is on (again as the view changes size), else at its own. */ + fit(): void { + const width = this.state?.settings.fitWidth ? this.state.width : null; + const view = this.deps.page.view.getBounds(); + const fitted = width === null ? '' : `${width}:${view.width}x${view.height}`; + // Hidden (no size), the page stays as it was laid out; it is again once shown. + if (fitted === this.fitted || (width !== null && !view.width)) return; + this.fitted = fitted; + fitToWidth(this.deps.page.view, width); + } + + /** Runs `task` (a capture) with the page as it is: the overlay hidden and the page at its own width. */ + async suspended(task: () => Promise): Promise { + const state = this.state; + if (!state) return task(); + await this.overlay.restyle({ ...state.settings, hidden: true }, state.width, state.height); + this.fitted = ''; + fitToWidth(this.deps.page.view, null); + try { + return await task(); + } finally { + if (this.state === state) { + await this.overlay.restyle(state.settings, state.width, state.height).catch(() => undefined); + this.fit(); + } + } + } + + private announce(): void { + this.deps.send({ type: 'overlay-changed', overlay: this.state }); + } +} diff --git a/src/main/overlay/PageOverlay.ts b/src/main/overlay/PageOverlay.ts new file mode 100644 index 0000000..ea3cbab --- /dev/null +++ b/src/main/overlay/PageOverlay.ts @@ -0,0 +1,62 @@ +import type { OverlaySettings } from '../../shared/types'; +import { CDP } from '../engine/constants'; +import type { CdpTransport } from '../engine/cdp'; +import { OVERLAY_GLOBAL, OVERLAY_WORLD } from './constants'; +import { overlayCall } from './overlayCall'; +import { OVERLAY_JS } from './overlaySource'; +import { overlayStyle } from './overlayStyle'; + +/** + * A design laid over a page's top frame through its CDP session: its code, style and image are put in every new + * document (in the overlay's world), and in the one loaded now. Changing the style replaces only the style's script. + */ +export class PageOverlay { + private scripts: { code?: string; style?: string; image?: string } = {}; + + constructor(private readonly transport: CdpTransport) {} + + /** Lays `base64` (an image file, `width` × `height` CSS pixels) over the page as set, replacing any design laid there before. */ + async show(base64: string, settings: OverlaySettings, width: number, height: number): Promise { + const css = overlayStyle(settings, width, height); + const { blend } = settings; + await this.remove(); + this.scripts = { code: await this.add(OVERLAY_JS), style: await this.add(overlayCall('setStyle', css, blend)), image: await this.add(overlayCall('setImage', base64)) }; + const world = await this.world(); + await this.run(world, OVERLAY_JS); + await this.run(world, overlayCall('setStyle', css, blend)); + await this.run(world, overlayCall('setImage', base64)); + } + + async restyle(settings: OverlaySettings, width: number, height: number): Promise { + const css = overlayStyle(settings, width, height); + const { blend } = settings; + if (this.scripts.style) await this.transport.send(CDP.Page.removeScriptToEvaluateOnNewDocument, { identifier: this.scripts.style }).catch(() => undefined); + this.scripts.style = await this.add(overlayCall('setStyle', css, blend)); + await this.run(await this.world(), overlayCall('setStyle', css, blend)); + } + + async remove(): Promise { + const ids = Object.values(this.scripts); + this.scripts = {}; + if (!ids.length) return; + await Promise.all(ids.map((identifier) => this.transport.send(CDP.Page.removeScriptToEvaluateOnNewDocument, { identifier }).catch(() => undefined))); + await this.run(await this.world(), `globalThis.${OVERLAY_GLOBAL} && ${overlayCall('remove')}`).catch(() => undefined); + } + + private async add(source: string): Promise { + const { identifier } = await this.transport.send<{ identifier: string }>(CDP.Page.addScriptToEvaluateOnNewDocument, { source, worldName: OVERLAY_WORLD }); + return identifier; + } + + /** The overlay's world in the document loaded now (made the first time, the same one after). */ + private async world(): Promise { + const { frameTree } = await this.transport.send<{ frameTree: { frame: { id: string } } }>(CDP.Page.getFrameTree); + const { executionContextId } = await this.transport.send<{ executionContextId: number }>(CDP.Page.createIsolatedWorld, { frameId: frameTree.frame.id, worldName: OVERLAY_WORLD }); + return executionContextId; + } + + private async run(contextId: number, expression: string): Promise { + const { exceptionDetails } = await this.transport.send<{ exceptionDetails?: { exception?: { description?: string } } }>(CDP.Runtime.evaluate, { expression, contextId, awaitPromise: true }); + if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? 'The overlay could not be laid over the page'); + } +} diff --git a/src/main/overlay/constants.ts b/src/main/overlay/constants.ts new file mode 100644 index 0000000..a5355e6 --- /dev/null +++ b/src/main/overlay/constants.ts @@ -0,0 +1,21 @@ +import type { OverlaySettings } from '../../shared/types'; + +/** The isolated world the overlay's code runs in: the page's scripts can't reach its functions (only the element). */ +export const OVERLAY_WORLD = 'console-editor-overlay'; + +/** The overlay's controller, a global of its world. */ +export const OVERLAY_GLOBAL = '__consoleEditorOverlay'; + +/** The overlay's element's id in the page. */ +export const OVERLAY_ELEMENT_ID = '__console-editor-overlay'; + +/** A new overlay: half see-through, where the page starts, scrolling with it, the page at the design's width. */ +export const DEFAULT_OVERLAY_SETTINGS: OverlaySettings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true }; + +/** Above anything the page stacks. */ +export const TOP_LAYER = 2147483647; + +/** The blend that needs a backdrop, what a page with no background of its own is given as one, and how "none" computes. */ +export const DIFFERENCE = 'difference'; +export const BACKDROP = '#ffffff'; +export const TRANSPARENT = 'rgba(0, 0, 0, 0)'; diff --git a/src/main/overlay/fitToWidth.ts b/src/main/overlay/fitToWidth.ts new file mode 100644 index 0000000..cd4d4a6 --- /dev/null +++ b/src/main/overlay/fitToWidth.ts @@ -0,0 +1,15 @@ +import type { WebContentsView } from 'electron'; + +/** + * Lays the page out `width` CSS pixels wide and, when the view is narrower, scales it down to fit (as DevTools' + * device mode does); `null` gives the page its own size back. Nothing changes while the view has no size (hidden). + */ +export function fitToWidth(view: WebContentsView, width: number | null): void { + const wc = view.webContents; + if (width === null) return wc.disableDeviceEmulation(); + const bounds = view.getBounds(); + if (!bounds.width || !bounds.height) return; + const scale = Math.min(1, bounds.width / width); + const size = { width, height: Math.round(bounds.height / scale) }; + wc.enableDeviceEmulation({ screenPosition: 'desktop', screenSize: size, viewPosition: { x: 0, y: 0 }, deviceScaleFactor: 0, viewSize: size, scale }); +} diff --git a/src/main/overlay/index.ts b/src/main/overlay/index.ts new file mode 100644 index 0000000..4a81cc8 --- /dev/null +++ b/src/main/overlay/index.ts @@ -0,0 +1 @@ +export { DesignOverlay } from './DesignOverlay'; diff --git a/src/main/overlay/overlayCall.ts b/src/main/overlay/overlayCall.ts new file mode 100644 index 0000000..59fa31c --- /dev/null +++ b/src/main/overlay/overlayCall.ts @@ -0,0 +1,6 @@ +import { OVERLAY_GLOBAL } from './constants'; + +/** A call of the overlay's controller in its world, its arguments written as JSON. */ +export function overlayCall(method: string, ...args: string[]): string { + return `globalThis.${OVERLAY_GLOBAL}.${method}(${args.map((a) => JSON.stringify(a)).join(', ')})`; +} diff --git a/src/main/overlay/overlaySource.ts b/src/main/overlay/overlaySource.ts new file mode 100644 index 0000000..dddea23 --- /dev/null +++ b/src/main/overlay/overlaySource.ts @@ -0,0 +1,80 @@ +import { BACKDROP, DIFFERENCE, OVERLAY_ELEMENT_ID, OVERLAY_GLOBAL, TRANSPARENT } from './constants'; + +/** + * The overlay's controller, run in its isolated world in each new document (and in the one loaded when a design is + * laid over it): `setImage` draws a design (base64) on a canvas added to `` once there is one, and put back if + * the page removes it; `setStyle` places and styles it; `remove` takes it off. A canvas, not an ``: a page's + * CSP can refuse a data: image, but not pixels drawn from bytes. Blended as a difference, it needs a backdrop: a page + * that paints no background of its own shows the browser's white, which nothing blends with, so it is given that + * white (no change to see) while it is, and its own back after. + */ +export const OVERLAY_JS = `(() => { + if (globalThis.${OVERLAY_GLOBAL}) return; + let canvas = null; + let style = ''; + let difference = false; + let keeper = null; + let ownBackground = null; + const base = () => { + const root = document.documentElement; + if (!root) return; + const clear = (el) => !el || getComputedStyle(el).backgroundColor === '${TRANSPARENT}'; + if (difference && canvas && ownBackground === null && clear(root) && clear(document.body)) { + ownBackground = root.style.getPropertyValue('background-color'); + root.style.setProperty('background-color', '${BACKDROP}'); + } else if ((!difference || !canvas) && ownBackground !== null) { + root.style.setProperty('background-color', ownBackground); + ownBackground = null; + } + }; + const attach = () => { + const root = document.documentElement; + if (!canvas) return; + if (!root) { + const waiting = new MutationObserver(() => { + if (!document.documentElement) return; + waiting.disconnect(); + attach(); + }); + waiting.observe(document, { childList: true }); + return; + } + if (!canvas.isConnected) root.appendChild(canvas); + base(); + if (keeper) return; + keeper = new MutationObserver(() => { + if (canvas && !canvas.isConnected && document.documentElement) document.documentElement.appendChild(canvas); + }); + keeper.observe(root, { childList: true }); + }; + globalThis.${OVERLAY_GLOBAL} = { + async setImage(base64) { + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes])); + if (!canvas) { + canvas = document.createElement('canvas'); + canvas.id = '${OVERLAY_ELEMENT_ID}'; + canvas.setAttribute('aria-hidden', 'true'); + } + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d').drawImage(bitmap, 0, 0); + bitmap.close(); + canvas.style.cssText = style; + attach(); + }, + setStyle(css, blend) { + style = css; + difference = blend === '${DIFFERENCE}'; + if (canvas) canvas.style.cssText = css; + if (document.body) base(); + }, + remove() { + if (keeper) keeper.disconnect(); + keeper = null; + if (canvas) canvas.remove(); + canvas = null; + base(); + }, + }; +})();`; diff --git a/src/main/overlay/overlayStyle.ts b/src/main/overlay/overlayStyle.ts new file mode 100644 index 0000000..e929a62 --- /dev/null +++ b/src/main/overlay/overlayStyle.ts @@ -0,0 +1,30 @@ +import type { OverlaySettings } from '../../shared/types'; +import { TOP_LAYER } from './constants'; + +/** + * The overlay's inline style: placed at its offset on the page (or the viewport), at its CSS size, as see-through + * and blended as set, above everything and out of the pointer's way. Each rule `!important`, so the page's CSS for + * canvases can't move it. + */ +export function overlayStyle(settings: OverlaySettings, width: number, height: number): string { + const rules: Record = { + position: settings.attached === 'page' ? 'absolute' : 'fixed', + left: `${settings.x}px`, + top: `${settings.y}px`, + width: `${width}px`, + height: `${height}px`, + 'max-width': 'none', + margin: 0, + padding: 0, + border: 0, + opacity: settings.opacity, + 'mix-blend-mode': settings.blend, + filter: settings.invert ? 'invert(1)' : 'none', + display: settings.hidden ? 'none' : 'block', + 'pointer-events': 'none', + 'z-index': TOP_LAYER, + }; + return Object.entries(rules) + .map(([name, value]) => `${name}: ${value} !important;`) + .join(' '); +} diff --git a/src/main/overlay/types.ts b/src/main/overlay/types.ts new file mode 100644 index 0000000..7f08187 --- /dev/null +++ b/src/main/overlay/types.ts @@ -0,0 +1,9 @@ +import type { AppEvent } from '../../shared/types'; +import type { PageController } from '../PageController'; +import type { ShotStore } from '../store/ShotStore'; + +export interface DesignOverlayDeps { + page: PageController; + store: ShotStore; + send(event: AppEvent): void; +} diff --git a/src/main/shots/PageShots/PageShots.ts b/src/main/shots/PageShots/PageShots.ts index bf8d617..67d4ad8 100644 --- a/src/main/shots/PageShots/PageShots.ts +++ b/src/main/shots/PageShots/PageShots.ts @@ -4,6 +4,7 @@ import { captureName } from '../captureName'; import { APP_BROWSER, CAPTURE_FORMAT, DESIGN_NAME } from '../constants'; import { designScale } from '../designScale'; import { imageInfo } from '../imageInfo'; +import { DesignOverlay } from '../../overlay'; import { makeThumbnail } from '../makeThumbnail'; import { waitUntilShown } from '../waitUntilShown'; import type { PageShotsDeps } from './types'; @@ -12,11 +13,16 @@ import type { PageShotsDeps } from './types'; const PAGE_AREAS: ReadonlySet = new Set(['viewport', 'page']); /** - * The workspace's captures and designs: taking captures of the app's page, keeping them, and every change announced - * as `shots-changed` (the active workspace's, newest first). + * The workspace's captures and designs: taking captures of the app's page (with the design laid over it taken off + * while they are), keeping them, and every change announced as `shots-changed` (the active workspace's, newest first). */ export class PageShots { - constructor(private readonly deps: PageShotsDeps) {} + /** The design laid over the page, if any. */ + readonly overlay: DesignOverlay; + + constructor(private readonly deps: PageShotsDeps) { + this.overlay = new DesignOverlay(deps); + } list(): Shot[] { return this.deps.store.list(); @@ -25,6 +31,8 @@ export class PageShots { /** The workspace whose shots are listed, and captures are added to. */ setWorkspace(id: string): void { this.deps.store.setWorkspace(id); + // A design of the workspace left behind isn't this one's. + void this.overlay.remove().catch(() => undefined); } /** Deletes every shot of a workspace (not the active one: nothing is announced). */ @@ -93,6 +101,7 @@ export class PageShots { async remove(id: unknown): Promise { await this.deps.store.remove(id); this.changed(); + await this.overlay.shotRemoved(id as string).catch(() => undefined); } /** Announces the active workspace's shots (after a change, or a workspace switch). */ @@ -107,7 +116,7 @@ export class PageShots { if (!url) throw new Error('Open a page first'); await waitUntilShown(page.view); const capture = () => captureOverCdp(page.cdp, target); - const image = await (around ? around(capture) : capture()); + const image = await this.overlay.suspended(() => (around ? around(capture) : capture())); return this.keep(image, url, target.area, { ...APP_BROWSER, version: process.versions.chrome }); } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 17daeb9..31b4643 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; import { IPC_CHANNEL } from '../shared/ipcChannels'; import type { ConsoleEditorApi, WireEvent } from '../shared/types'; import { browsersApi } from './browsersApi'; +import { overlayApi } from './overlayApi'; import { shotsApi } from './shotsApi'; /** The global the renderer reaches the API through (`window.consoleEditor`, declared in its shared/api). */ @@ -89,6 +90,7 @@ const api: ConsoleEditorApi = { ...browsersApi, ...shotsApi, + ...overlayApi, getActionsWindow: () => ipcRenderer.invoke(IPC_CHANNEL.getActionsWindow), detachActions: () => ipcRenderer.invoke(IPC_CHANNEL.detachActions), diff --git a/src/preload/overlayApi.ts b/src/preload/overlayApi.ts new file mode 100644 index 0000000..7c4c9ee --- /dev/null +++ b/src/preload/overlayApi.ts @@ -0,0 +1,11 @@ +import { ipcRenderer } from 'electron'; +import { IPC_CHANNEL } from '../shared/ipcChannels'; +import type { OverlayApi } from '../shared/types'; + +/** The design overlay's part of the bridge. */ +export const overlayApi: OverlayApi = { + getOverlay: () => ipcRenderer.invoke(IPC_CHANNEL.getOverlay), + showOverlay: (shotId) => ipcRenderer.invoke(IPC_CHANNEL.showOverlay, shotId), + updateOverlay: (patch) => ipcRenderer.invoke(IPC_CHANNEL.updateOverlay, patch), + removeOverlay: () => ipcRenderer.invoke(IPC_CHANNEL.removeOverlay), +}; diff --git a/src/renderer/src/app/model/bridge/appEventHandlers.ts b/src/renderer/src/app/model/bridge/appEventHandlers.ts index 62ff426..3c35591 100644 --- a/src/renderer/src/app/model/bridge/appEventHandlers.ts +++ b/src/renderer/src/app/model/bridge/appEventHandlers.ts @@ -1,5 +1,6 @@ import { useActionStore } from '@/entities/action'; import { useBrowserStore } from '@/entities/browser'; +import { useShotStore } from '@/entities/shot'; import { useOverrideStore } from '@/entities/override'; import { useConsoleStore } from '@/entities/console-log'; import { useFrameStore } from '@/entities/frame'; @@ -73,6 +74,7 @@ export const APP_EVENT_HANDLERS: AppEventHandlers = { 'browsers-changed': (event) => useBrowserStore.getState().setAll(event.browsers), 'shots-changed': (event) => receiveShots(event.shots), 'show-shot': (event) => showShotById(event.id), + 'overlay-changed': (event) => useShotStore.getState().setOverlay(event.overlay), command: (event) => runCommand(event.command), 'flush-session': answerFlushSession, update: (event) => handleUpdateState(event.state), diff --git a/src/renderer/src/app/model/bridge/startBridge.ts b/src/renderer/src/app/model/bridge/startBridge.ts index b5ddc49..15402a2 100644 --- a/src/renderer/src/app/model/bridge/startBridge.ts +++ b/src/renderer/src/app/model/bridge/startBridge.ts @@ -7,6 +7,7 @@ import { useRenderLog, useStoreLog } from '@/entities/inspector'; import { usePageStackStore } from '@/entities/page-stack'; import { useRuleStore } from '@/entities/rule'; import { useSettingsStore } from '@/entities/settings'; +import { useShotStore } from '@/entities/shot'; import { useWorkspaceStore } from '@/entities/workspace'; import { loadBrowsers } from '@/features/browser/open-in-browser'; import { receiveEntries } from '@/features/filter-console'; @@ -55,6 +56,7 @@ export async function startBridge(commands: PageCommands, session: PageSession): api.listNetworkRequests().then((requests) => receiveNetworkRequests(requests, true)), api.listHeldRequests().then(receiveHeld), api.listShots().then(receiveShots), + api.getOverlay().then((overlay) => useShotStore.getState().setOverlay(overlay)), ]); // Not waited for: looking for the installed browsers is the system's pace, and only the toolbar's menu needs them. diff --git a/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts b/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts index a88b17a..a03201c 100644 --- a/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts +++ b/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts @@ -14,4 +14,5 @@ export const PAGE_WINDOW_EVENT_HANDLERS: PageWindowEventHandlers = { command: (event) => PAGE_WINDOW_COMMANDS[event.command]?.(), 'browsers-changed': (event) => useBrowserStore.getState().setAll(event.browsers), 'shots-changed': (event) => useShotStore.getState().setAll(event.shots), + 'overlay-changed': (event) => useShotStore.getState().setOverlay(event.overlay), }; diff --git a/src/renderer/src/app/model/page-window-bridge/startPageWindowBridge.ts b/src/renderer/src/app/model/page-window-bridge/startPageWindowBridge.ts index 25b9ea7..b27bbe3 100644 --- a/src/renderer/src/app/model/page-window-bridge/startPageWindowBridge.ts +++ b/src/renderer/src/app/model/page-window-bridge/startPageWindowBridge.ts @@ -11,6 +11,7 @@ export async function startPageWindowBridge(): Promise<() => void> { // Applied as its reply arrives, in order with the events around it (a later one is newer). usePageStore.getState().setPage(await api.getPageState()); void api.listShots().then((shots) => useShotStore.getState().setAll(shots), () => undefined); + void api.getOverlay().then((overlay) => useShotStore.getState().setOverlay(overlay), () => undefined); void loadBrowsers(); return off; } diff --git a/src/renderer/src/entities/shot/model/store/types.ts b/src/renderer/src/entities/shot/model/store/types.ts index a0ce627..9655a44 100644 --- a/src/renderer/src/entities/shot/model/store/types.ts +++ b/src/renderer/src/entities/shot/model/store/types.ts @@ -1,8 +1,11 @@ -import type { Shot } from '@common/types'; +import type { OverlayState, Shot } from '@common/types'; export interface ShotStore { /** The active workspace's captures and designs, newest first. */ shots: Shot[]; + /** The design laid over the page, if any. */ + overlay: OverlayState | null; setAll(shots: Shot[]): void; + setOverlay(overlay: OverlayState | null): void; } diff --git a/src/renderer/src/entities/shot/model/store/useShotStore.ts b/src/renderer/src/entities/shot/model/store/useShotStore.ts index 2baa259..b975b6b 100644 --- a/src/renderer/src/entities/shot/model/store/useShotStore.ts +++ b/src/renderer/src/entities/shot/model/store/useShotStore.ts @@ -1,8 +1,10 @@ import { create } from 'zustand'; import type { ShotStore } from './types'; -/** The active workspace's captures and designs (mirrors the main process's, which announces every change). */ +/** The active workspace's captures and designs, and the design over the page (mirror the main process's, which announces every change). */ export const useShotStore = create()((set) => ({ shots: [], + overlay: null, setAll: (shots) => set({ shots }), + setOverlay: (overlay) => set({ overlay }), })); diff --git a/src/renderer/src/features/shot/overlay/index.ts b/src/renderer/src/features/shot/overlay/index.ts new file mode 100644 index 0000000..74ca16f --- /dev/null +++ b/src/renderer/src/features/shot/overlay/index.ts @@ -0,0 +1 @@ +export { removeOverlay, showOverlay, updateOverlay } from './model'; diff --git a/src/renderer/src/features/shot/overlay/model/index.ts b/src/renderer/src/features/shot/overlay/model/index.ts new file mode 100644 index 0000000..85d5a6d --- /dev/null +++ b/src/renderer/src/features/shot/overlay/model/index.ts @@ -0,0 +1,3 @@ +export { removeOverlay } from './removeOverlay'; +export { showOverlay } from './showOverlay'; +export { updateOverlay } from './updateOverlay'; diff --git a/src/renderer/src/features/shot/overlay/model/overlayUpdates.ts b/src/renderer/src/features/shot/overlay/model/overlayUpdates.ts new file mode 100644 index 0000000..d691a86 --- /dev/null +++ b/src/renderer/src/features/shot/overlay/model/overlayUpdates.ts @@ -0,0 +1,4 @@ +import type { OverlaySettings } from '@common/types'; + +/** Settings changes not sent yet, and whether one is on its way: shared by the files that queue and send them. */ +export const overlayUpdates: { pending: Partial | null; sending: boolean } = { pending: null, sending: false }; diff --git a/src/renderer/src/features/shot/overlay/model/removeOverlay.ts b/src/renderer/src/features/shot/overlay/model/removeOverlay.ts new file mode 100644 index 0000000..770352a --- /dev/null +++ b/src/renderer/src/features/shot/overlay/model/removeOverlay.ts @@ -0,0 +1,11 @@ +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Takes the design off the page (and gives the page its own width back). */ +export async function removeOverlay(): Promise { + try { + await api.removeOverlay(); + } catch (err) { + toast({ title: 'Could not take the design off the page', description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/shot/overlay/model/showOverlay.ts b/src/renderer/src/features/shot/overlay/model/showOverlay.ts new file mode 100644 index 0000000..d2473c5 --- /dev/null +++ b/src/renderer/src/features/shot/overlay/model/showOverlay.ts @@ -0,0 +1,12 @@ +import type { Shot } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Lays a design (or capture) over the page; its bar shows under the preview's toolbar. */ +export async function showOverlay(shot: Pick): Promise { + try { + await api.showOverlay(shot.id); + } catch (err) { + toast({ title: `Could not lay ${shot.name} over the page`, description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/shot/overlay/model/updateOverlay.ts b/src/renderer/src/features/shot/overlay/model/updateOverlay.ts new file mode 100644 index 0000000..89a0bc0 --- /dev/null +++ b/src/renderer/src/features/shot/overlay/model/updateOverlay.ts @@ -0,0 +1,30 @@ +import type { OverlaySettings } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; +import { useShotStore } from '@/entities/shot'; +import { overlayUpdates } from './overlayUpdates'; + +/** + * Changes how the design over the page shows: at once in the bar, then in the page. Changes made while one is on its + * way (a slider dragged) are sent together once it lands, so the page isn't restyled for every step. + */ +export async function updateOverlay(patch: Partial): Promise { + const { overlay, setOverlay } = useShotStore.getState(); + if (!overlay) return; + setOverlay({ ...overlay, settings: { ...overlay.settings, ...patch } }); + overlayUpdates.pending = { ...overlayUpdates.pending, ...patch }; + if (overlayUpdates.sending) return; + overlayUpdates.sending = true; + try { + while (overlayUpdates.pending) { + const next = overlayUpdates.pending; + overlayUpdates.pending = null; + await api.updateOverlay(next); + } + } catch (err) { + overlayUpdates.pending = null; + toast({ title: 'Could not change the design over the page', description: errorMessage(err), tone: 'danger' }); + } finally { + overlayUpdates.sending = false; + } +} diff --git a/src/renderer/src/shared/config/icons.ts b/src/renderer/src/shared/config/icons.ts index d5c71d7..f1fef0d 100644 --- a/src/renderer/src/shared/config/icons.ts +++ b/src/renderer/src/shared/config/icons.ts @@ -8,6 +8,7 @@ export { AlertCircleIcon as ErrorIcon, ApiIcon as ResponseIcon, AppWindowIcon as PreviewIcon, + AnchorIcon, ArrowDataTransferHorizontalIcon as CorsIcon, ArrowDataTransferVerticalIcon as NetworkIcon, ArrowDown01Icon as ChevronDownIcon, @@ -25,6 +26,7 @@ export { Clock01Icon as DelayIcon, CodeFolderIcon as SourceRootIcon, CodeIcon as DevToolsIcon, + ContrastIcon, ComponentIcon, CommandLineIcon as ConsoleIcon, Copy01Icon as CopyIcon, @@ -48,12 +50,14 @@ export { FloppyDiskIcon as SaveIcon, FullScreenIcon as FullPageIcon, Folder01Icon as FolderIcon, + FitToScreenIcon as FitWidthIcon, FolderOpenIcon, GitCompareIcon as DiffIcon, Globe02Icon as GlobeIcon, HierarchyIcon as TreeViewIcon, Html5Icon as HtmlIcon, Image01Icon as ShotIcon, + ImageCompositionIcon as OverlayIcon, ImageUpload01Icon as ImportDesignIcon, InformationCircleIcon as InfoIcon, JavaScriptIcon as JsIcon, @@ -86,6 +90,8 @@ export { Tick02Icon as CheckIcon, TypescriptIcon as TsIcon, UnavailableIcon as BlockIcon, + ViewIcon as ShowIcon, + ViewOffIcon as HideIcon, WifiDisconnected01Icon as FailIcon, ZoomInAreaIcon as ZoomInIcon, ZoomOutAreaIcon as ZoomOutIcon, diff --git a/src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx b/src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx index 8682fdc..de7b8f9 100644 --- a/src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx +++ b/src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx @@ -3,11 +3,13 @@ import { api } from '@/shared/api'; import { icons } from '@/shared/config'; import { IconButton } from '@/shared/ui/icon-button'; import { copyShot, deleteShot, saveShotAs } from '@/features/shot/manage'; +import { showOverlay } from '@/features/shot/overlay'; -/** What can be done with a shot from its page: copy it, save a copy, show its file, delete it. */ +/** What can be done with a shot from its page: lay it over the page, copy it, save a copy, show its file, delete it. */ export function ShotActions({ shot }: { shot: Shot }) { return (
+ void showOverlay(shot)} data-testid="shot-overlay" /> void copyShot(shot)} /> void saveShotAs(shot)} /> void api.showShotFile(shot.id)} /> diff --git a/src/renderer/src/widgets/page-preview/ui/OverlayBar/OffsetField.tsx b/src/renderer/src/widgets/page-preview/ui/OverlayBar/OffsetField.tsx new file mode 100644 index 0000000..79c0494 --- /dev/null +++ b/src/renderer/src/widgets/page-preview/ui/OverlayBar/OffsetField.tsx @@ -0,0 +1,23 @@ +export interface OffsetFieldProps { + axis: 'x' | 'y'; + value: number; + onChange(value: number): void; +} + +/** Where the design is across or down, in whole CSS pixels. */ +export function OffsetField({ axis, value, onChange }: OffsetFieldProps) { + return ( + + ); +} diff --git a/src/renderer/src/widgets/page-preview/ui/OverlayBar/OverlayBar.tsx b/src/renderer/src/widgets/page-preview/ui/OverlayBar/OverlayBar.tsx new file mode 100644 index 0000000..5dfc6b1 --- /dev/null +++ b/src/renderer/src/widgets/page-preview/ui/OverlayBar/OverlayBar.tsx @@ -0,0 +1,53 @@ +import type { KeyboardEvent } from 'react'; +import { icons } from '@/shared/config'; +import { IconButton } from '@/shared/ui/icon-button'; +import { ShotThumb, useShotStore } from '@/entities/shot'; +import { removeOverlay, updateOverlay } from '@/features/shot/overlay'; +import { NUDGE, OPACITY_STEP, SHIFT_NUDGE } from './constants'; +import { OffsetField } from './OffsetField'; + +/** + * Under the preview's toolbar while a design is over the page: how see-through it is, blending and inverting it, where + * it is (the arrow keys nudge it while the bar has focus, Shift by 10), whether it scrolls with the page, the page at + * its width, hiding it, and taking it off. + */ +export function OverlayBar() { + const overlay = useShotStore((s) => s.overlay); + if (!overlay) return null; + const { settings } = overlay; + const nudge = (event: KeyboardEvent) => { + const step = NUDGE[event.key]; + if (!step || event.target instanceof HTMLInputElement) return; + event.preventDefault(); + const times = event.shiftKey ? SHIFT_NUDGE : 1; + void updateOverlay({ x: settings.x + step.x * times, y: settings.y + step.y * times }); + }; + return ( +
+ + + {overlay.name} + + void updateOverlay({ opacity: Number(event.target.value) })} + aria-label="How see-through the design is" + className="w-24 shrink-0 accent-accent" + data-testid="overlay-opacity" + /> + {Math.round(settings.opacity * 100)}% + void updateOverlay({ blend: settings.blend === 'difference' ? 'normal' : 'difference' })} data-testid="overlay-difference" /> + void updateOverlay({ x })} /> + void updateOverlay({ y })} /> + void updateOverlay({ attached: settings.attached === 'page' ? 'viewport' : 'page' })} data-testid="overlay-attached" /> + void updateOverlay({ fitWidth: !settings.fitWidth })} data-testid="overlay-fit" /> + + void updateOverlay({ hidden: !settings.hidden })} data-testid="overlay-hide" /> + void removeOverlay()} data-testid="overlay-remove" /> +
+ ); +} diff --git a/src/renderer/src/widgets/page-preview/ui/OverlayBar/constants.ts b/src/renderer/src/widgets/page-preview/ui/OverlayBar/constants.ts new file mode 100644 index 0000000..91994a6 --- /dev/null +++ b/src/renderer/src/widgets/page-preview/ui/OverlayBar/constants.ts @@ -0,0 +1,15 @@ +import { KEY } from '@/shared/config'; + +/** Nudging the design with the arrow keys (the bar focused): how far each moves it, in CSS pixels. */ +export const NUDGE: Readonly> = { + [KEY.arrowLeft]: { x: -1, y: 0 }, + [KEY.arrowRight]: { x: 1, y: 0 }, + [KEY.arrowUp]: { x: 0, y: -1 }, + [KEY.arrowDown]: { x: 0, y: 1 }, +}; + +/** Shift nudges this many times as far. */ +export const SHIFT_NUDGE = 10; + +/** The opacity slider's step. */ +export const OPACITY_STEP = 0.05; diff --git a/src/renderer/src/widgets/page-preview/ui/OverlayBar/index.ts b/src/renderer/src/widgets/page-preview/ui/OverlayBar/index.ts new file mode 100644 index 0000000..31a1624 --- /dev/null +++ b/src/renderer/src/widgets/page-preview/ui/OverlayBar/index.ts @@ -0,0 +1 @@ +export { OverlayBar } from './OverlayBar'; diff --git a/src/renderer/src/widgets/page-preview/ui/PagePreview/PagePreview.tsx b/src/renderer/src/widgets/page-preview/ui/PagePreview/PagePreview.tsx index b5b1027..9cc659f 100644 --- a/src/renderer/src/widgets/page-preview/ui/PagePreview/PagePreview.tsx +++ b/src/renderer/src/widgets/page-preview/ui/PagePreview/PagePreview.tsx @@ -1,5 +1,6 @@ import { selectAnyOverlayOpen, useOverlayStore } from '@/shared/lib'; import { selectHasPage, usePageStore } from '@/entities/page'; +import { OverlayBar } from '../OverlayBar'; import { EmptyPreview } from './EmptyPreview'; import { PageSnapshot } from './PageSnapshot'; import { PreviewToolbar } from './PreviewToolbar'; @@ -25,6 +26,7 @@ export function PagePreview({ placement = 'editor', suspended = false, layoutKey return (
+
diff --git a/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotRow.tsx b/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotRow.tsx index 4f9e8e5..e7ff2c6 100644 --- a/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotRow.tsx +++ b/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotRow.tsx @@ -5,6 +5,7 @@ import { Icon } from '@/shared/ui/icon'; import { ContextMenu, type MenuItem } from '@/shared/ui/menu'; import { shotDetail, ShotThumb } from '@/entities/shot'; import { copyShot, deleteShot, saveShotAs } from '@/features/shot/manage'; +import { showOverlay } from '@/features/shot/overlay'; import { ROW_THUMB } from './constants'; export interface ShotRowProps { @@ -19,6 +20,7 @@ export interface ShotRowProps { export function ShotRow({ shot, now, canDelete, onOpen }: ShotRowProps) { const items: MenuItem[] = [ { label: 'Open', icon: icons.ShotIcon, onSelect: () => onOpen(shot) }, + { label: 'Put over the page', icon: icons.OverlayIcon, onSelect: () => void showOverlay(shot) }, { label: 'Copy image', icon: icons.CopyIcon, onSelect: () => void copyShot(shot) }, { label: 'Save a copy…', icon: icons.DownloadIcon, onSelect: () => void saveShotAs(shot) }, { label: 'Show in folder', icon: icons.FolderOpenIcon, onSelect: () => void api.showShotFile(shot.id) }, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6dded73..46e99e2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -24,7 +24,7 @@ export const ACTIONS_WINDOW_EVENTS = ['actions-changed', 'actions-window', 'fram * The events the website's own window is sent besides its page's state and its shortcut: what its toolbar's menus * show. Its UI has a handler for each, so one added here fails typecheck until it is handled there. */ -export const PAGE_WINDOW_EVENTS = ['browsers-changed', 'shots-changed'] as const satisfies readonly AppEvent['type'][]; +export const PAGE_WINDOW_EVENTS = ['browsers-changed', 'shots-changed', 'overlay-changed'] as const satisfies readonly AppEvent['type'][]; /** * The events that can carry megabytes (a batch of commits of hundreds of components, of requests with their diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts index a98f3d1..b54b4ca 100644 --- a/src/shared/ipcChannels.ts +++ b/src/shared/ipcChannels.ts @@ -108,6 +108,11 @@ export const IPC_CHANNEL = { setShotScale: 'shots:scale', captureForDesign: 'shots:capture-for-design', + getOverlay: 'overlay:get', + showOverlay: 'overlay:show', + updateOverlay: 'overlay:update', + removeOverlay: 'overlay:remove', + getActionsWindow: 'actions-window:state', detachActions: 'actions-window:detach', attachActions: 'actions-window:attach', diff --git a/src/shared/overlay/index.ts b/src/shared/overlay/index.ts new file mode 100644 index 0000000..6be83df --- /dev/null +++ b/src/shared/overlay/index.ts @@ -0,0 +1 @@ +export { overlaySettingsSchema } from './overlaySettingsSchema'; diff --git a/src/shared/overlay/overlaySettingsSchema.ts b/src/shared/overlay/overlaySettingsSchema.ts new file mode 100644 index 0000000..a515d9c --- /dev/null +++ b/src/shared/overlay/overlaySettingsSchema.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import type { OverlaySettings } from '../types'; + +/** How far a design can be moved from the page's top left, either way, in CSS pixels. */ +const MAX_OFFSET = 100_000; + +/** A design overlay's settings: each within its range; unknown keys are dropped. */ +export const overlaySettingsSchema = z.toZod()( + z.object({ + opacity: z.number().min(0).max(1), + blend: z.enum(['normal', 'difference']), + invert: z.boolean(), + x: z.number().int().min(-MAX_OFFSET).max(MAX_OFFSET), + y: z.number().int().min(-MAX_OFFSET).max(MAX_OFFSET), + attached: z.enum(['page', 'viewport']), + hidden: z.boolean(), + fitWidth: z.boolean(), + }), +); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 8b1129f..dd54037 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -1,5 +1,6 @@ import type { ActionInput, ActionPatch, ActionsWindowState, ConsoleAction } from './actions'; import type { BrowsersApi } from './browsersApi'; +import type { OverlayApi } from './overlayApi'; import type { ShotsApi } from './shotsApi'; import type { ConsoleEntry, ConsoleFrame, ConsoleProperty } from './console'; import type { InspectorApi } from './inspector'; @@ -17,7 +18,7 @@ import type { AppInfo, UpdateState } from './updates'; import type { Workspace, WorkspacePatch, WorkspacesState } from './workspaces'; /** The API exposed to the renderer as `window.consoleEditor`. */ -export interface ConsoleEditorApi extends InspectorApi, NetworkApi, BrowsersApi, ShotsApi { +export interface ConsoleEditorApi extends InspectorApi, NetworkApi, BrowsersApi, ShotsApi, OverlayApi { navigate(url: string): Promise; reload(): Promise; goBack(): Promise; diff --git a/src/shared/types/events.ts b/src/shared/types/events.ts index 0b7846a..bbb56fd 100644 --- a/src/shared/types/events.ts +++ b/src/shared/types/events.ts @@ -5,6 +5,7 @@ import type { ConsoleEntry, ConsoleFrame } from './console'; import type { FrameStack, InspectedComponent, InspectHover, RenderCommit, StoreAction } from './inspector'; import type { MenuCommand } from './menu'; import type { NetworkRequest } from './network'; +import type { OverlayState } from './overlay'; import type { OverrideMeta, UnpatchedReason } from './overrides'; import type { PageState } from './page'; import type { ResourceEntry } from './resources'; @@ -91,6 +92,8 @@ export type AppEvent = | { type: 'browsers-changed'; browsers: BrowserInfo[] } /** The active workspace's shots, newest first: one was taken, imported, renamed or deleted, or another workspace became active. */ | { type: 'shots-changed'; shots: Shot[] } + /** A design was laid over the page, its settings changed, or it was taken off (null). */ + | { type: 'overlay-changed'; overlay: OverlayState | null } /** Show a shot's page (asked for from the website's own window). */ | { type: 'show-shot'; id: string } /** The window is closing: write pending drafts, then call `sessionFlushed`. */ diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index d8755dd..868e8da 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -52,6 +52,8 @@ export type { HarImport, HttpHeader, NetworkBody, NetworkBodyGap, NetworkRequest export type { CreateOverrideInput, MatchType, Override, OverrideMeta, OverridePatch, OverrideWithContent, RequestMatch, ResponseSettings, UnpatchedReason, UrlMatcher } from './overrides'; export { MATCH_TYPES } from './overrides'; export type { ExportedOverride, ExportedRule, OverridesExport, OverridesFile, OverridesFileEntries, OverridesImport } from './overridesFile'; +export type { OverlaySettings, OverlayState } from './overlay'; +export type { OverlayApi } from './overlayApi'; export type { PageState, Rect } from './page'; export type { FileKind, ResourceContent, ResourceEntry, ResourceKind } from './resources'; export { FILE_KINDS, RESOURCE_KINDS } from './resources'; diff --git a/src/shared/types/overlay.ts b/src/shared/types/overlay.ts new file mode 100644 index 0000000..72b3257 --- /dev/null +++ b/src/shared/types/overlay.ts @@ -0,0 +1,27 @@ +/** How a design laid over the page shows. */ +export interface OverlaySettings { + /** 0 (hidden) to 1 (opaque). */ + opacity: number; + /** `difference`: what matches the page goes black, what doesn't lights up. */ + blend: 'normal' | 'difference'; + /** Its colours inverted (with the page's, what matches goes grey). */ + invert: boolean; + /** Where its top left is on the page (or the viewport), in CSS pixels. */ + x: number; + y: number; + /** `page`: it scrolls with the page. `viewport`: it stays put while the page scrolls under it. */ + attached: 'page' | 'viewport'; + hidden: boolean; + /** The page is laid out at the design's width, and scaled to fit the preview when that is narrower. */ + fitWidth: boolean; +} + +/** The design laid over the page, while there is one. */ +export interface OverlayState { + shotId: string; + name: string; + /** Its size in CSS pixels. */ + width: number; + height: number; + settings: OverlaySettings; +} diff --git a/src/shared/types/overlayApi.ts b/src/shared/types/overlayApi.ts new file mode 100644 index 0000000..9a4993e --- /dev/null +++ b/src/shared/types/overlayApi.ts @@ -0,0 +1,11 @@ +import type { OverlaySettings, OverlayState } from './overlay'; + +/** The design overlay's part of the API exposed to the renderer (`ConsoleEditorApi`). */ +export interface OverlayApi { + /** The design laid over the page, if any. */ + getOverlay(): Promise; + /** Lays a design (or capture) over the page, in its top frame, after reloads too; it replaces any other. */ + showOverlay(shotId: string): Promise; + updateOverlay(patch: Partial): Promise; + removeOverlay(): Promise; +} diff --git a/test/e2e/designs.e2e.test.ts b/test/e2e/designs.e2e.test.ts index 95d8f2c..e1fd2dc 100644 --- a/test/e2e/designs.e2e.test.ts +++ b/test/e2e/designs.e2e.test.ts @@ -40,7 +40,19 @@ describe.skipIf(!built)('Designs', () => { let dir: string; let app: ElectronApplication; let win: Page; + let origin: string; + /** The pixel at x, y of a shot's PNG, read in main. */ + const pixelOf = (id: string, x: number, y: number) => + app.evaluate( + ({ nativeImage }, [dir, id, x, y]) => { + const image = nativeImage.createFromPath(`${dir}/user-data/workspace/shots/${id}.png`); + const bgra = image.toBitmap(); + const i = (y * image.getSize().width + x) * 4; + return [bgra[i + 2], bgra[i + 1], bgra[i]]; + }, + [dir, id, x, y] as const, + ); const shots = () => win.evaluate(() => (window as unknown as { consoleEditor: { listShots(): Promise } }).consoleEditor.listShots()); const menu = () => win.getByTestId('shots-menu'); @@ -58,7 +70,7 @@ describe.skipIf(!built)('Designs', () => { app = await electron.launch({ args: [...sandboxArgs, root], cwd: root, env: { ...process.env, CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data') } as Record }); win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); await win.waitForSelector('body[data-ready]'); - const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; await win.getByTestId('address-bar').fill(`${origin}/`); await win.getByTestId('address-bar').press('Enter'); await waitFor(() => app.evaluate(({ webContents }, o) => webContents.getAllWebContents().some((wc) => wc.getURL() === `${o}/` && !wc.isLoading()), origin)); @@ -116,6 +128,44 @@ describe.skipIf(!built)('Designs', () => { await expect.poll(async () => (await shots()).find((s) => s.id === design.id)?.scale).toBe(1); }); + it('lays the design over the page at its width, keeps it out of captures and after a reload, and takes it off', async () => { + const pageJs = (code: string) => app.evaluate(({ webContents }, [o, code]) => webContents.getAllWebContents().find((wc) => wc.getURL() === `${o}/`)!.executeJavaScript(code), [origin, code] as const); + const overlayStyle = () => pageJs(`document.getElementById('__console-editor-overlay')?.style.cssText ?? null`) as Promise; + await win.getByTestId('shot-scale-2').click(); + await win.getByTestId('shot-overlay').click(); + await win.getByTestId('overlay-bar').waitFor(); + await expect.poll(overlayStyle).toContain('width: 400px'); + expect(await overlayStyle()).toContain('opacity: 0.5'); + // The page is laid out at the design's width. + await expect.poll(() => pageJs('innerWidth')).toBe(400); + + await win.getByTestId('overlay-opacity').fill('1'); + await expect.poll(overlayStyle).toContain('opacity: 1'); + await win.getByTestId('overlay-difference').click(); + await expect.poll(overlayStyle).toContain('mix-blend-mode: difference'); + + // A capture shows the page alone, at its own width; the design is back afterwards. + const viewWidth = await app.evaluate(({ BrowserWindow, WebContentsView }, o) => { + for (const w of BrowserWindow.getAllWindows()) { + const view = w.contentView.children.find((v) => v instanceof WebContentsView && v.webContents.getURL() === `${o}/`); + if (view) return view.getBounds().width; + } + return 0; + }, origin); + const capture = await win.evaluate(() => (window as unknown as { consoleEditor: { captureShot(area: string): Promise } }).consoleEditor.captureShot('viewport')); + expect(capture.width).toBe(viewWidth); + expect(await pixelOf(capture.id, 30, 30)).toEqual([0, 0, 255]); + await expect.poll(() => pageJs('innerWidth')).toBe(400); + + await pageJs('location.reload()'); + await expect.poll(overlayStyle, { timeout: 15_000 }).toContain('mix-blend-mode: difference'); + + await win.getByTestId('overlay-remove').click(); + await expect.poll(overlayStyle).toBeNull(); + await expect.poll(() => pageJs('innerWidth')).toBe(viewWidth); + expect(await win.getByTestId('overlay-bar').count()).toBe(0); + }); + it('keeps an image dropped on the shots menu as a design', async () => { await win.getByTestId('shots-menu-button').click(); const png = encodePng(20, 10, () => RED).toString('base64'); diff --git a/test/integration/overlay.chromium.test.ts b/test/integration/overlay.chromium.test.ts new file mode 100644 index 0000000..c9e4202 --- /dev/null +++ b/test/integration/overlay.chromium.test.ts @@ -0,0 +1,102 @@ +/** + * A design laid over a page through CDP, against real Chromium, on a page whose CSP forbids images from anywhere: + * drawn all the same (a canvas in an isolated world), restyled, kept after a reload, out of the pointer's way, and + * taken off. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { captureOverCdp } from '../../src/main/shots/capture'; +import { OVERLAY_ELEMENT_ID } from '../../src/main/overlay/constants'; +import { PageOverlay } from '../../src/main/overlay/PageOverlay'; +import { DEFAULT_OVERLAY_SETTINGS } from '../../src/main/overlay/constants'; +import { chromiumAvailable, launchChromium, type ChromiumHarness } from '../helpers/chromium'; +import { decodePng } from '../helpers/decodePng'; +import { encodePng } from '../helpers/encodePng'; + +describe.skipIf(!chromiumAvailable)('design overlay in Chromium', () => { + let server: Server; + let chrome: ChromiumHarness; + let page: Page; + let transport: Awaited>['transport']; + let overlay: PageOverlay; + const design = encodePng(200, 100, () => [255, 0, 0]).toString('base64'); + const opaque = { ...DEFAULT_OVERLAY_SETTINGS, opacity: 1, x: 10, y: 20 }; + const pixel = async (x: number, y: number) => decodePng((await captureOverCdp(transport, { area: 'viewport' })).bytes).at(x, y); + + beforeAll(async () => { + server = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" }); + res.end(''); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + chrome = await launchChromium(); + ({ page, transport } = await chrome.newPage()); + await page.setViewportSize({ width: 400, height: 300 }); + await transport.send('Page.enable'); + await page.goto(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`); + overlay = new PageOverlay(transport); + }); + + afterAll(async () => { + await chrome?.close(); + await new Promise((r) => server?.close(r)); + }); + + it('draws the design over the page where it is placed, although the CSP allows no images', async () => { + await overlay.show(design, opaque, 200, 100); + await expect.poll(() => pixel(15, 25)).toEqual([255, 0, 0, 255]); + expect(await pixel(5, 5)).toEqual([0, 0, 255, 255]); + expect(await pixel(215, 125)).toEqual([0, 0, 255, 255]); + }); + + it('lets the pointer through to the page under it', async () => { + expect(await page.evaluate(() => document.elementFromPoint(50, 50)?.id)).toBe('under'); + }); + + it('takes a new style: see-through, blended, moved, hidden', async () => { + await overlay.restyle({ ...opaque, opacity: 0.5 }, 200, 100); + const [r, , b] = await pixel(15, 25); + expect(r).toBeGreaterThan(100); + expect(b).toBeGreaterThan(100); + await overlay.restyle({ ...opaque, blend: 'difference' }, 200, 100); + // Red on blue, as a difference: magenta. + expect(await pixel(15, 25)).toEqual([255, 0, 255, 255]); + await overlay.restyle({ ...opaque, x: 100 }, 200, 100); + expect(await pixel(15, 25)).toEqual([0, 0, 255, 255]); + expect(await pixel(105, 25)).toEqual([255, 0, 0, 255]); + await overlay.restyle({ ...opaque, hidden: true }, 200, 100); + expect(await pixel(15, 25)).toEqual([0, 0, 255, 255]); + await overlay.restyle(opaque, 200, 100); + }); + + it('is there again after a reload, with its latest style, and puts itself back when the page removes it', async () => { + await overlay.restyle({ ...opaque, x: 50 }, 200, 100); + await page.reload(); + await expect.poll(() => pixel(55, 25)).toEqual([255, 0, 0, 255]); + expect(await pixel(15, 25)).toEqual([0, 0, 255, 255]); + await page.evaluate((id) => document.getElementById(id)?.remove(), OVERLAY_ELEMENT_ID); + await expect.poll(() => page.evaluate((id) => !!document.getElementById(id), OVERLAY_ELEMENT_ID)).toBe(true); + }); + + it('blends as a difference with the white a page without a background shows, and gives the page its own back', async () => { + const root = () => page.evaluate(() => document.documentElement.style.backgroundColor); + await page.evaluate(() => document.body.style.setProperty('background', 'none')); + await overlay.restyle({ ...opaque, blend: 'difference' }, 200, 100); + // Red from white: cyan. + expect(await pixel(15, 25)).toEqual([0, 255, 255, 255]); + expect(await root()).toBe('rgb(255, 255, 255)'); + await overlay.restyle(opaque, 200, 100); + expect(await root()).toBe(''); + await page.evaluate(() => document.body.style.setProperty('background', '#0000ff')); + }); + + it('comes off, and stays off after a reload', async () => { + await overlay.remove(); + expect(await pixel(55, 25)).toEqual([0, 0, 255, 255]); + await page.reload(); + expect(await page.evaluate((id) => !!document.getElementById(id), OVERLAY_ELEMENT_ID)).toBe(false); + expect(await pixel(55, 25)).toEqual([0, 0, 255, 255]); + }); +}); diff --git a/test/renderer/bridge-start.test.ts b/test/renderer/bridge-start.test.ts index 0d881d6..f173493 100644 --- a/test/renderer/bridge-start.test.ts +++ b/test/renderer/bridge-start.test.ts @@ -26,6 +26,7 @@ const api = vi.hoisted(() => ({ listNetworkRequests: vi.fn(), listHeldRequests: vi.fn(), listShots: vi.fn(), + getOverlay: vi.fn(), listBrowsers: vi.fn(), getPageState: vi.fn(), sessionFlushed: vi.fn(), @@ -117,6 +118,7 @@ describe('start bridge', () => { api.listNetworkRequests.mockResolvedValue([]); api.listHeldRequests.mockResolvedValue([]); api.listShots.mockResolvedValue([]); + api.getOverlay.mockResolvedValue(null); api.listBrowsers.mockResolvedValue([]); api.getPageState.mockResolvedValue(PAGE); }); From 7cb1e83c4d3be49d20f897cfb52addc0033d6679 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 21:29:34 +0000 Subject: [PATCH 06/19] Open the page in a Chromium browser with the workspace's changes A Chromium browser found on the computer can now be launched with a profile of the app's own and a debugging port. Every one of its tabs is served the workspace's overrides and rules by the same interception engine as the app's page; a new tab waits until that is set up. Changes to overrides, rules and settings reach those tabs as they are made, and the tabs reload when "Reload page after changes" is on. The browser menu lists each driven browser's tabs: they can be brought to the front, opened in the app or captured into the shots. Letting go of a browser leaves it open. When the app starts again it reconnects to a browser that is still open instead of launching a second one. --- .jscpd-baseline.json | 1 - src/main/browsers/driven/DrivenBrowsers.ts | 129 +++++++++++++++ src/main/browsers/driven/DrivenChromium.ts | 148 ++++++++++++++++++ src/main/browsers/driven/DrivenTabs.ts | 63 ++++++++ src/main/browsers/driven/attachTab.ts | 30 ++++ src/main/browsers/driven/constants.ts | 49 ++++++ src/main/browsers/driven/driveCommand.ts | 14 ++ src/main/browsers/driven/index.ts | 1 + src/main/browsers/driven/launchForDriving.ts | 28 ++++ src/main/browsers/driven/profileDir.ts | 19 +++ src/main/browsers/driven/reachOrLaunch.ts | 15 ++ src/main/browsers/driven/readActivePort.ts | 10 ++ src/main/browsers/driven/types.ts | 54 +++++++ src/main/browsers/index.ts | 1 + src/main/engine/constants/cdp.ts | 145 +---------------- src/main/engine/constants/cdpDomains.ts | 56 +++++++ src/main/engine/constants/cdpTargets.ts | 100 ++++++++++++ .../websocketTransport/CdpConnection.ts | 12 +- .../engine/websocketTransport/attachToPage.ts | 47 +----- src/main/engine/websocketTransport/index.ts | 2 + .../websocketTransport/pageTransport.ts | 47 ++++++ src/main/engine/websocketTransport/types.ts | 5 + src/main/ipc/registerBrowserIpc.ts | 29 +++- src/main/ipc/registerIpc.ts | 6 +- src/main/ipc/registerShotIpc.ts | 12 +- src/main/ipc/types.ts | 4 +- src/main/launch/createWindow.ts | 9 +- src/main/shots/PageShots/PageShots.ts | 5 +- src/main/shots/constants.ts | 3 + src/preload/browsersApi.ts | 4 + src/preload/shotsApi.ts | 1 + .../src/app/model/bridge/appEventHandlers.ts | 1 + .../pageWindowEventHandlers.ts | 1 + .../src/entities/browser/model/store/types.ts | 5 +- .../browser/model/store/useBrowserStore.ts | 2 + .../src/entities/shot/lib/matchesShot.ts | 7 +- .../features/browser/open-in-browser/index.ts | 2 +- .../open-in-browser/model/activateTab.ts | 12 ++ .../open-in-browser/model/captureTab.ts | 13 ++ .../browser/open-in-browser/model/index.ts | 6 + .../open-in-browser/model/loadBrowsers.ts | 11 +- .../open-in-browser/model/matchesBrowser.ts | 7 +- .../open-in-browser/model/matchesTab.ts | 7 + .../open-in-browser/model/openTabHere.ts | 12 ++ .../open-in-browser/model/openWithChanges.ts | 13 ++ .../open-in-browser/model/stopDriving.ts | 12 ++ .../open-in-browser/ui/BrowserMenu.tsx | 18 ++- .../browser/open-in-browser/ui/BrowserRow.tsx | 48 ++++-- .../open-in-browser/ui/DrivenSection.tsx | 41 +++++ .../open-in-browser/ui/DrivenTabRow.tsx | 42 +++++ src/renderer/src/shared/lib/index.ts | 2 +- src/renderer/src/shared/lib/match/index.ts | 1 + .../src/shared/lib/match/matchesWords.ts | 8 + .../command-palette/ui/browserItems.ts | 34 ++-- src/shared/constants.ts | 2 +- src/shared/ipcChannels.ts | 5 + src/shared/types/browsers.ts | 17 ++ src/shared/types/browsersApi.ts | 13 +- src/shared/types/events.ts | 4 +- src/shared/types/index.ts | 2 +- src/shared/types/shotsApi.ts | 2 + test/e2e/driven.e2e.test.ts | 131 ++++++++++++++++ test/integration/driven.chromium.test.ts | 144 +++++++++++++++++ test/renderer/bridge-start.test.ts | 2 + test/unit/browsers.test.ts | 68 +++++++- 65 files changed, 1493 insertions(+), 251 deletions(-) create mode 100644 src/main/browsers/driven/DrivenBrowsers.ts create mode 100644 src/main/browsers/driven/DrivenChromium.ts create mode 100644 src/main/browsers/driven/DrivenTabs.ts create mode 100644 src/main/browsers/driven/attachTab.ts create mode 100644 src/main/browsers/driven/constants.ts create mode 100644 src/main/browsers/driven/driveCommand.ts create mode 100644 src/main/browsers/driven/index.ts create mode 100644 src/main/browsers/driven/launchForDriving.ts create mode 100644 src/main/browsers/driven/profileDir.ts create mode 100644 src/main/browsers/driven/reachOrLaunch.ts create mode 100644 src/main/browsers/driven/readActivePort.ts create mode 100644 src/main/browsers/driven/types.ts create mode 100644 src/main/engine/constants/cdpDomains.ts create mode 100644 src/main/engine/constants/cdpTargets.ts create mode 100644 src/main/engine/websocketTransport/pageTransport.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/activateTab.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/captureTab.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/matchesTab.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/openTabHere.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/openWithChanges.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/stopDriving.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/DrivenSection.tsx create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/DrivenTabRow.tsx create mode 100644 src/renderer/src/shared/lib/match/matchesWords.ts create mode 100644 test/e2e/driven.e2e.test.ts create mode 100644 test/integration/driven.chromium.test.ts diff --git a/.jscpd-baseline.json b/.jscpd-baseline.json index 75910ca..a5e7432 100644 --- a/.jscpd-baseline.json +++ b/.jscpd-baseline.json @@ -7,7 +7,6 @@ "8a060e80d0b739f6": 1, "938e89086d5dc1f1": 1, "9bf006a9ac0610a2": 1, - "a2ffe737302d8522": 1, "abecc2a6b6900f86": 1, "ade32fb042158fe0": 1, "b44f3991fa8ccb97": 1, diff --git a/src/main/browsers/driven/DrivenBrowsers.ts b/src/main/browsers/driven/DrivenBrowsers.ts new file mode 100644 index 0000000..85d0445 --- /dev/null +++ b/src/main/browsers/driven/DrivenBrowsers.ts @@ -0,0 +1,129 @@ +import type { AppEvent, CaptureArea, DrivenBrowser, ShotBrowser } from '../../../shared/types'; +import { HTTP_URL } from '../../constants'; +import { CDP } from '../../engine/constants'; +import type { CapturedImage } from '../../shots/capture'; +import { PAGE_AREAS } from '../../shots/constants'; +import type { FoundBrowser } from '../types'; +import { PRODUCT_VERSION, RELOAD_DEBOUNCE_MS } from './constants'; +import { DrivenChromium } from './DrivenChromium'; +import { profileDir } from './profileDir'; +import { reachOrLaunch } from './reachOrLaunch'; +import type { DrivenBrowsersDeps } from './types'; + +/** What each app event means for the driven browsers; the rest mean nothing to them. */ +type AppEventReactions = Partial void>>; + +/** + * The Chromium browsers the app drives, one per installed browser, each with a profile of the app's own: launched (or + * reached again) when an address is first opened in one with the workspace's changes, and kept in step with them. + * Every change is announced as `driven-browsers-changed`. + */ +export class DrivenBrowsers { + private readonly driven = new Map(); + private readonly starting = new Map>(); + private reloadTimer: ReturnType | undefined; + private readonly reactions: AppEventReactions = { + 'overrides-changed': () => this.served(), + 'rules-changed': () => this.served(), + 'settings-changed': () => void Promise.all(this.all().map((d) => d.applySettings())), + }; + + constructor(private readonly deps: DrivenBrowsersDeps) {} + + list(): DrivenBrowser[] { + return this.all().map((d) => d.list()); + } + + /** The driven browsers with their tabs' titles and addresses as they are now. */ + async read(): Promise { + await Promise.all(this.all().map((d) => d.readTabs())); + return this.list(); + } + + /** Opens an http(s) address in a Chromium browser with the workspace's changes, launching it if need be. */ + async open(id: string, url: string): Promise { + if (!HTTP_URL.test(url)) throw new Error('Only http(s) pages open in another browser'); + const browser = await this.deps.registry.get(id); + if (browser.engine !== 'chromium') throw new Error(`${browser.name} can't be served your changes: only Chromium browsers can, for now`); + await (await this.reach(browser)).open(url); + } + + activate(browserId: string, tabId: string): Promise { + return this.get(browserId).activate(tabId); + } + + /** Captures a tab of a driven browser, with the address it showed and the browser it was taken in. */ + async capture(browserId: string, tabId: string, area: unknown): Promise<{ image: CapturedImage; url: string; browser: ShotBrowser }> { + if (!PAGE_AREAS.has(area)) throw new Error('Invalid capture area'); + const driven = this.get(browserId); + const { id, name, version } = driven.list(); + return { ...(await driven.capture(tabId, area as Exclude)), browser: { id, name, version } }; + } + + /** Stops serving the workspace's changes in a browser; it stays open. */ + stop(browserId: string): void { + this.get(browserId).stop(); + this.driven.delete(browserId); + this.changed(); + } + + /** Told every app event: overrides, rules and settings that change are served in the driven tabs too. */ + onAppEvent(event: AppEvent): void { + this.reactions[event.type]?.(); + } + + /** Lets go of every driven browser (the app is quitting); they stay open. */ + dispose(): void { + clearTimeout(this.reloadTimer); + for (const driven of this.all()) driven.stop(); + this.driven.clear(); + } + + private all(): DrivenChromium[] { + return [...this.driven.values()]; + } + + private get(id: string): DrivenChromium { + const driven = this.driven.get(id); + if (!driven) throw new Error("That browser isn't open with your changes any more"); + return driven; + } + + /** The driven browser for an installed one: already connected, connecting, or launched now. */ + private reach(browser: FoundBrowser): Promise { + const driven = this.driven.get(browser.id); + if (driven) return Promise.resolve(driven); + const starting = this.starting.get(browser.id) ?? this.connect(browser).finally(() => this.starting.delete(browser.id)); + this.starting.set(browser.id, starting); + return starting; + } + + private async connect(browser: FoundBrowser): Promise { + const connection = await reachOrLaunch(browser, profileDir(browser, this.deps.userData)); + const { product } = await connection.send<{ product: string }>(CDP.Browser.getVersion); + const driven = new DrivenChromium(browser, PRODUCT_VERSION.exec(product)?.[1] ?? null, connection, { + sources: this.deps.sources, + changed: () => this.changed(), + closed: () => { + this.driven.delete(browser.id); + this.changed(); + }, + }); + this.driven.set(browser.id, driven); + await driven.start(); + this.changed(); + return driven; + } + + /** Overrides or rules changed: each tab intercepts what they now need, and reloads (after a burst, once) if the settings say so. */ + private served(): void { + void Promise.all(this.all().map((d) => d.refresh())); + if (!this.deps.sources.settings.get().autoReloadOnSave || this.driven.size === 0) return; + clearTimeout(this.reloadTimer); + this.reloadTimer = setTimeout(() => void Promise.all(this.all().map((d) => d.reload())), RELOAD_DEBOUNCE_MS); + } + + private changed(): void { + this.deps.send({ type: 'driven-browsers-changed', driven: this.list() }); + } +} diff --git a/src/main/browsers/driven/DrivenChromium.ts b/src/main/browsers/driven/DrivenChromium.ts new file mode 100644 index 0000000..ed4da6e --- /dev/null +++ b/src/main/browsers/driven/DrivenChromium.ts @@ -0,0 +1,148 @@ +import type { CaptureArea, DrivenBrowser } from '../../../shared/types'; +import { HTTP_URL } from '../../constants'; +import { CDP } from '../../engine/constants'; +import { withTimeout } from '../../engine/PageInterception'; +import type { CdpConnection } from '../../engine/websocketTransport'; +import { captureOverCdp, type CapturedImage } from '../../shots/capture'; +import type { FoundBrowser } from '../types'; +import { attachTab } from './attachTab'; +import { NEW_TAB_TIMEOUT_MS, PAGE_ATTACH, PAGE_TARGET, START_URL, STOP_ATTACH } from './constants'; +import { DrivenTabs } from './DrivenTabs'; +import type { AttachedPage, DrivenChromiumDeps, DrivenTabState, PageTargetInfo } from './types'; + +/** + * A Chromium browser the app launched, over its browser-level CDP connection: every tab is attached (a new one before + * it loads anything) and served the workspace's overrides and rules by an interception of its own, the same engine as + * the app's page. Tabs are listed with their address and title as they change. + */ +export class DrivenChromium { + private readonly tabs = new DrivenTabs(); + private readonly disposers: Array<() => void> = []; + + constructor( + readonly browser: FoundBrowser, + private readonly version: string | null, + private readonly connection: CdpConnection, + private readonly deps: DrivenChromiumDeps, + ) {} + + /** Attaches to the browser's tabs, the ones open now and every one opened later. */ + async start(): Promise { + const events: Partial void>> = { + [CDP.Target.attachedToTarget]: (p: AttachedPage) => this.attached(p), + [CDP.Target.detachedFromTarget]: (p: { sessionId: string }) => this.detached(p.sessionId), + [CDP.Target.targetInfoChanged]: (p: { targetInfo: PageTargetInfo }) => this.tabs.update(p.targetInfo) && this.deps.changed(), + }; + this.disposers.push( + // The browser's own events carry no session; its tabs' go to their interceptions. + this.connection.onEvent((method, params, sessionId) => { + if (!sessionId) events[method]?.(params); + }), + this.connection.onClose(() => this.closed()), + ); + await this.connection.send(CDP.Target.setDiscoverTargets, { discover: true }); + await this.connection.send(CDP.Target.setAutoAttach, { ...PAGE_ATTACH }); + } + + list(): DrivenBrowser { + return { id: this.browser.id, name: this.browser.name, version: this.version, tabs: this.tabs.list() }; + } + + /** + * Reads each tab's title and address again: a title a page sets isn't announced (only its address is), so it is + * read once the page has loaded, and whenever the tabs are listed. + */ + async readTabs(ids = this.tabs.list().map((t) => t.id)): Promise { + const infos = await Promise.all(ids.map((targetId) => this.connection.send<{ targetInfo: PageTargetInfo }>(CDP.Target.getTargetInfo, { targetId }).catch(() => null))); + if (infos.filter((info) => info && this.tabs.update(info.targetInfo)).length) this.deps.changed(); + } + + /** Opens an address in the blank tab the browser started on, or else in a new tab, and brings it to the front. */ + async open(url: string): Promise { + const tab = this.tabs.blank() ?? (await this.newTab()); + // Taken: another address opened before this one shows isn't loaded in it too. + tab.info = { ...tab.info, url }; + await tab.ready; + await tab.transport.send(CDP.Page.navigate, { url }); + await this.activate(tab.info.id); + } + + /** Brings a tab to the front, in its window. */ + async activate(tabId: string): Promise { + this.tabs.get(tabId); + await this.connection.send(CDP.Target.activateTarget, { targetId: tabId }); + } + + /** Captures a tab (brought to the front first: a hidden tab isn't drawn); with the address it showed. */ + async capture(tabId: string, area: Exclude): Promise<{ image: CapturedImage; url: string }> { + const tab = this.tabs.get(tabId); + await this.activate(tabId); + return { image: await captureOverCdp(tab.transport, { area }), url: tab.info.url }; + } + + /** After overrides or rules changed: what each tab intercepts. */ + async refresh(): Promise { + await Promise.all(this.tabs.all().map((t) => t.interception.refreshInterception().catch(() => undefined))); + } + + /** After the settings changed: the cache, throttling and service workers they set, in each tab. */ + async applySettings(): Promise { + await Promise.all(this.tabs.all().map((t) => t.interception.applySettings().catch(() => undefined))); + } + + /** Reloads the tabs showing a website, so what changed is served. */ + async reload(): Promise { + const pages = this.tabs.all().filter((t) => HTTP_URL.test(t.info.url)); + await Promise.all(pages.map((t) => this.reloadTab(t).catch(() => undefined))); + } + + /** Stops serving the workspace's changes: the browser stays open, as it is. */ + stop(): void { + for (const dispose of this.disposers.splice(0)) dispose(); + for (const tab of this.tabs.clear()) this.release(tab); + this.connection.send(CDP.Target.setAutoAttach, { ...STOP_ATTACH }).catch(() => undefined); + this.connection.close(); + } + + private attached(p: AttachedPage): void { + if (p.targetInfo.type !== PAGE_TARGET) { + // Only tabs are asked for; anything else is let go of as it came. + this.connection.send(CDP.Runtime.runIfWaitingForDebugger, {}, p.sessionId).catch(() => undefined); + this.connection.send(CDP.Target.detachFromTarget, { sessionId: p.sessionId }).catch(() => undefined); + return; + } + const tab = attachTab(this.connection, p, this.deps.sources); + tab.transport.on(CDP.Page.loadEventFired, () => void this.readTabs([tab.info.id])); + this.tabs.add(tab); + this.deps.changed(); + } + + private detached(sessionId: string): void { + const tab = this.tabs.remove(sessionId); + if (!tab) return; + this.release(tab); + this.deps.changed(); + } + + private async newTab(): Promise { + const { targetId } = await this.connection.send<{ targetId: string }>(CDP.Target.createTarget, { url: START_URL }); + return withTimeout(this.tabs.arrival(targetId), NEW_TAB_TIMEOUT_MS, 'Opening a tab'); + } + + private async reloadTab(tab: DrivenTabState): Promise { + await tab.interception.prepareReload(tab.info.url); + await tab.transport.send(CDP.Page.reload, { ignoreCache: true }); + } + + private release(tab: DrivenTabState): void { + tab.interception.detach(); + void tab.transport.detach(); + } + + /** The browser was quit: its tabs are gone with it. */ + private closed(): void { + for (const dispose of this.disposers.splice(0)) dispose(); + for (const tab of this.tabs.clear()) tab.interception.detach(); + this.deps.closed(); + } +} diff --git a/src/main/browsers/driven/DrivenTabs.ts b/src/main/browsers/driven/DrivenTabs.ts new file mode 100644 index 0000000..fc9d8d6 --- /dev/null +++ b/src/main/browsers/driven/DrivenTabs.ts @@ -0,0 +1,63 @@ +import type { DrivenTab } from '../../../shared/types'; +import { START_URL } from './constants'; +import type { DrivenTabState, PageTargetInfo } from './types'; + +/** A driven browser's tabs, in the order they were attached; waits for the tab a new target becomes. */ +export class DrivenTabs { + private readonly tabs = new Map(); + private readonly arrivals = new Map void>(); + + add(tab: DrivenTabState): void { + this.tabs.set(tab.info.id, tab); + this.arrivals.get(tab.info.id)?.(tab); + this.arrivals.delete(tab.info.id); + } + + /** Forgets the tab whose session went, and returns it. */ + remove(sessionId: string): DrivenTabState | undefined { + const tab = this.all().find((t) => t.sessionId === sessionId); + if (tab) this.tabs.delete(tab.info.id); + return tab; + } + + /** Takes a tab's new address or title; false when it isn't a tab here, or neither changed. */ + update({ targetId, url, title }: PageTargetInfo): boolean { + const tab = this.tabs.get(targetId); + if (!tab || (tab.info.url === url && tab.info.title === title)) return false; + tab.info = { ...tab.info, url, title }; + return true; + } + + get(id: string): DrivenTabState { + const tab = this.tabs.get(id); + if (!tab) throw new Error('That tab is closed'); + return tab; + } + + /** The tab of a target just created, once it is attached. */ + arrival(targetId: string): Promise { + const tab = this.tabs.get(targetId); + return tab ? Promise.resolve(tab) : new Promise((resolve) => this.arrivals.set(targetId, resolve)); + } + + /** A blank tab (the one the browser started on) to load an address in, rather than opening another. */ + blank(): DrivenTabState | undefined { + return this.all().find((t) => t.info.url === START_URL); + } + + all(): DrivenTabState[] { + return [...this.tabs.values()]; + } + + list(): DrivenTab[] { + return this.all().map((t) => t.info); + } + + /** Forgets every tab, and returns them. */ + clear(): DrivenTabState[] { + const all = this.all(); + this.tabs.clear(); + this.arrivals.clear(); + return all; + } +} diff --git a/src/main/browsers/driven/attachTab.ts b/src/main/browsers/driven/attachTab.ts new file mode 100644 index 0000000..ddf126a --- /dev/null +++ b/src/main/browsers/driven/attachTab.ts @@ -0,0 +1,30 @@ +import { CDP } from '../../engine/constants'; +import { PageInterception } from '../../engine/PageInterception'; +import { pageTransport, type CdpConnection } from '../../engine/websocketTransport'; +import type { AttachedPage, DrivenTabState, InterceptionSources } from './types'; + +/** + * Serves the workspace's changes in a tab just attached: an interception of its own on its session, set up before + * the tab (paused, when it is new) is let run. + */ +export function attachTab(connection: CdpConnection, { sessionId, targetInfo }: AttachedPage, { store, rules, settings }: InterceptionSources): DrivenTabState { + const transport = pageTransport(connection, sessionId); + const interception = new PageInterception({ + transport, + getOverrides: () => store.list(), + getRules: () => rules.list(), + getSettings: () => settings.get(), + getOverrideBase: (id) => store.base(id), + // The app's network list and console are its own page's: nothing is reported from other browsers. + emit: () => undefined, + }); + const ready = interception + .attach() + .catch(() => undefined) + .then(() => transport.send(CDP.Runtime.runIfWaitingForDebugger)) + .then( + () => undefined, + () => undefined, + ); + return { info: { id: targetInfo.targetId, title: targetInfo.title, url: targetInfo.url }, sessionId, transport, interception, ready }; +} diff --git a/src/main/browsers/driven/constants.ts b/src/main/browsers/driven/constants.ts new file mode 100644 index 0000000..cd3f84f --- /dev/null +++ b/src/main/browsers/driven/constants.ts @@ -0,0 +1,49 @@ +/** Where a driven browser's profile is kept, in the app's data folder (one folder per browser). */ +export const PROFILES_DIR = 'browsers'; + +/** The profile folder's name inside a Snap's or Flatpak's own writable folder. */ +export const SANDBOXED_PROFILE = 'console-editor-profile'; + +/** A Snap's own writable folder: `~/snap//common`; a Flatpak's: `~/.var/app//data`. */ +export const SANDBOX_DIRS = { snapBin: '/snap/bin/', snap: 'snap', snapCommon: 'common', flatpak: 'flatpak', flatpakRun: 'run', flatpakData: '.var/app', flatpakDataSub: 'data' } as const; + +/** The file Chromium writes in its profile when started with a debugging port: the port, then the browser's path. */ +export const ACTIVE_PORT_FILE = 'DevToolsActivePort'; + +/** Waiting for a launched browser to write it. */ +export const ACTIVE_PORT_WAIT = { timeoutMs: 30_000, stepMs: 150 } as const; + +/** What a browser started to be driven is given: a debugging port the system picks, and no first-run pages. */ +export const DRIVE_FLAGS = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check'] as const; +export const USER_DATA_FLAG = '--user-data-dir='; +export const DISABLE_FEATURES_FLAG = '--disable-features='; + +/** The page a driven browser starts on: nothing loads before the app has attached to it. */ +export const START_URL = 'about:blank'; + +/** macOS: `open -n -a --args ` starts a new instance with flags. */ +export const MAC_OPEN = { newInstance: '-n', args: '--args' } as const; + +/** Flatpak's markers around forwarded files: flags go before them. */ +export const FLATPAK_MARKERS: readonly string[] = ['@@u', '@@']; + +/** The target type of a tab. */ +export const PAGE_TARGET = 'page'; + +/** + * Every tab of a driven browser is attached, and one opened later waits (before it loads anything) until its + * interception is set up. Its frames and workers are attached below it, by its own interception. + */ +export const PAGE_ATTACH = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, filter: [{ type: PAGE_TARGET }, { exclude: true }] } as const; + +/** Letting go of a driven browser's tabs: new ones start as they would. */ +export const STOP_ATTACH = { autoAttach: false, waitForDebuggerOnStart: false, flatten: true } as const; + +/** How long a tab the app opens has to be attached before opening it fails. */ +export const NEW_TAB_TIMEOUT_MS = 10_000; + +/** `Browser.getVersion`'s product: `Chrome/140.0.7339.80`, the version after the slash. */ +export const PRODUCT_VERSION = /\/(\S+)$/; + +/** Waiting this long after overrides or rules change before reloading driven tabs (a burst reloads them once). */ +export const RELOAD_DEBOUNCE_MS = 300; diff --git a/src/main/browsers/driven/driveCommand.ts b/src/main/browsers/driven/driveCommand.ts new file mode 100644 index 0000000..720390e --- /dev/null +++ b/src/main/browsers/driven/driveCommand.ts @@ -0,0 +1,14 @@ +import { FLATPAK_MARKERS, MAC_OPEN } from './constants'; +import type { BrowserCommand } from '../types'; +import { OPEN_COMMAND } from '../findBrowsers/mac/constants'; + +/** + * A browser's command with `flags` and `url` added so the browser takes them: after `--args` for a macOS app (in a + * new instance), before Flatpak's forwarded-file markers, else where the address goes. + */ +export function driveCommand({ command, urlAt }: BrowserCommand, flags: string[], url: string): string[] { + if (command[0] === OPEN_COMMAND) return [command[0], MAC_OPEN.newInstance, ...command.slice(1), MAC_OPEN.args, ...flags, url]; + const marker = command.findIndex((arg) => FLATPAK_MARKERS.includes(arg)); + const at = marker >= 0 ? Math.min(marker, urlAt) : urlAt; + return [...command.slice(0, at), ...flags, url, ...command.slice(at).filter((arg) => !FLATPAK_MARKERS.includes(arg))]; +} diff --git a/src/main/browsers/driven/index.ts b/src/main/browsers/driven/index.ts new file mode 100644 index 0000000..eab4ede --- /dev/null +++ b/src/main/browsers/driven/index.ts @@ -0,0 +1 @@ +export { DrivenBrowsers } from './DrivenBrowsers'; diff --git a/src/main/browsers/driven/launchForDriving.ts b/src/main/browsers/driven/launchForDriving.ts new file mode 100644 index 0000000..10deedc --- /dev/null +++ b/src/main/browsers/driven/launchForDriving.ts @@ -0,0 +1,28 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { LOCAL_NETWORK_ACCESS_FEATURES } from '../../chromiumFlags'; +import { startBrowser } from '../startBrowser'; +import type { FoundBrowser } from '../types'; +import { ACTIVE_PORT_FILE, ACTIVE_PORT_WAIT, DISABLE_FEATURES_FLAG, DRIVE_FLAGS, START_URL, USER_DATA_FLAG } from './constants'; +import { driveCommand } from './driveCommand'; +import { readActivePort } from './readActivePort'; + +/** + * Starts a Chromium browser to be driven: with its profile in `dir`, a debugging port, the app's Chromium switches, on + * a blank page; resolves with its WebSocket address once it has written it. + */ +export async function launchForDriving(browser: FoundBrowser, dir: string): Promise { + await mkdir(dir, { recursive: true }); + // A file left by an earlier run names a port no longer listened on. + await rm(join(dir, ACTIVE_PORT_FILE), { force: true }); + const flags = [`${USER_DATA_FLAG}${dir}`, ...DRIVE_FLAGS, `${DISABLE_FEATURES_FLAG}${LOCAL_NETWORK_ACCESS_FEATURES.join(',')}`]; + const [program, ...args] = driveCommand(browser, flags, START_URL); + await startBrowser({ command: [program, ...args], urlAt: args.length + 1 }, []); + const deadline = Date.now() + ACTIVE_PORT_WAIT.timeoutMs; + for (;;) { + const address = await readActivePort(dir); + if (address) return address; + if (Date.now() > deadline) throw new Error(`${browser.name} didn't open its debugging port`); + await new Promise((resolve) => setTimeout(resolve, ACTIVE_PORT_WAIT.stepMs)); + } +} diff --git a/src/main/browsers/driven/profileDir.ts b/src/main/browsers/driven/profileDir.ts new file mode 100644 index 0000000..baef523 --- /dev/null +++ b/src/main/browsers/driven/profileDir.ts @@ -0,0 +1,19 @@ +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; +import type { FoundBrowser } from '../types'; +import { PROFILES_DIR, SANDBOX_DIRS, SANDBOXED_PROFILE } from './constants'; + +/** + * Where a driven browser keeps its profile: in the app's data folder, or, for a Snap or a Flatpak, in the folder its + * sandbox lets it write (and the app read). + */ +export function profileDir(browser: FoundBrowser, userData: string): string { + const program = browser.program ?? ''; + if (program.startsWith(SANDBOX_DIRS.snapBin)) return join(homedir(), SANDBOX_DIRS.snap, basename(program), SANDBOX_DIRS.snapCommon, SANDBOXED_PROFILE); + if (basename(program) === SANDBOX_DIRS.flatpak) { + const run = browser.command.indexOf(SANDBOX_DIRS.flatpakRun); + const appId = browser.command.slice(run + 1).find((arg) => !arg.startsWith('-')); + if (run >= 0 && appId) return join(homedir(), SANDBOX_DIRS.flatpakData, appId, SANDBOX_DIRS.flatpakDataSub, SANDBOXED_PROFILE); + } + return join(userData, PROFILES_DIR, browser.id.replace(/[^a-z0-9._-]+/gi, '_')); +} diff --git a/src/main/browsers/driven/reachOrLaunch.ts b/src/main/browsers/driven/reachOrLaunch.ts new file mode 100644 index 0000000..9144c8f --- /dev/null +++ b/src/main/browsers/driven/reachOrLaunch.ts @@ -0,0 +1,15 @@ +import { CdpConnection } from '../../engine/websocketTransport'; +import type { FoundBrowser } from '../types'; +import { launchForDriving } from './launchForDriving'; +import { readActivePort } from './readActivePort'; + +/** + * A connection to the browser driven with the profile in `dir`: the one the app launched before, if it is still open + * (the app was restarted, or stopped driving it), else one launched now. A browser already open with that profile + * would take a second launch's address itself, and never open a debugging port for it. + */ +export async function reachOrLaunch(browser: FoundBrowser, dir: string): Promise { + const running = await readActivePort(dir); + const reached = running ? await CdpConnection.connect(running).catch(() => null) : null; + return reached ?? CdpConnection.connect(await launchForDriving(browser, dir)); +} diff --git a/src/main/browsers/driven/readActivePort.ts b/src/main/browsers/driven/readActivePort.ts new file mode 100644 index 0000000..dec7776 --- /dev/null +++ b/src/main/browsers/driven/readActivePort.ts @@ -0,0 +1,10 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ACTIVE_PORT_FILE } from './constants'; + +/** The WebSocket address of the browser whose profile is `dir`, from the file it wrote there; null when there is none (yet). */ +export async function readActivePort(dir: string): Promise { + const text = await readFile(join(dir, ACTIVE_PORT_FILE), 'utf8').catch(() => ''); + const [port, path] = text.split(/\r?\n/); + return /^\d+$/.test(port ?? '') && path?.startsWith('/') ? `ws://127.0.0.1:${port}${path}` : null; +} diff --git a/src/main/browsers/driven/types.ts b/src/main/browsers/driven/types.ts new file mode 100644 index 0000000..3e8e881 --- /dev/null +++ b/src/main/browsers/driven/types.ts @@ -0,0 +1,54 @@ +import type { AppEvent, DrivenTab } from '../../../shared/types'; +import type { PageInterception } from '../../engine/PageInterception'; +import type { PageTransport } from '../../engine/websocketTransport'; +import type { OverrideStore } from '../../store/OverrideStore'; +import type { RuleStore } from '../../store/RuleStore'; +import type { SettingsStore } from '../../store/SettingsStore'; +import type { FoundBrowser } from '../types'; + +/** What a driven browser's tabs are served: the active workspace's overrides and rules, and the settings. */ +export interface InterceptionSources { + store: OverrideStore; + rules: RuleStore; + settings: SettingsStore; +} + +/** A tab of a driven browser, with the interception serving it. */ +export interface DrivenTabState { + info: DrivenTab; + sessionId: string; + transport: PageTransport; + interception: PageInterception; + /** Settles once its interception is set up (or couldn't be) and the tab runs. */ + ready: Promise; +} + +/** What `Target.attachedToTarget` and `Target.targetInfoChanged` say of a target, as far as a tab needs. */ +export interface PageTargetInfo { + targetId: string; + type: string; + url: string; + title: string; +} + +export interface AttachedPage { + sessionId: string; + targetInfo: PageTargetInfo; +} + +export interface DrivenChromiumDeps { + sources: InterceptionSources; + /** A tab opened, closed, or changed its address or title. */ + changed(): void; + /** The browser went away (it was quit). */ + closed(): void; +} + +export interface DrivenBrowsersDeps { + /** Where the browsers are found. */ + registry: { get(id: string): Promise }; + sources: InterceptionSources; + /** The app's data folder, where driven browsers keep their profiles. */ + userData: string; + send(event: AppEvent): void; +} diff --git a/src/main/browsers/index.ts b/src/main/browsers/index.ts index 3d3de50..a40c920 100644 --- a/src/main/browsers/index.ts +++ b/src/main/browsers/index.ts @@ -1,2 +1,3 @@ export { BrowserRegistry } from './BrowserRegistry'; +export { DrivenBrowsers } from './driven'; export type { FoundBrowser } from './types'; diff --git a/src/main/engine/constants/cdp.ts b/src/main/engine/constants/cdp.ts index 31c645b..2df4fc2 100644 --- a/src/main/engine/constants/cdp.ts +++ b/src/main/engine/constants/cdp.ts @@ -1,145 +1,8 @@ +import { CDP_DOMAINS } from './cdpDomains'; +import { CDP_TARGETS } from './cdpTargets'; + /** * The Chrome DevTools Protocol commands and events the engine uses, keyed by * domain and name as the protocol spells them: `CDP.Fetch.enable` is `Fetch.enable`. */ -export const CDP = { - Fetch: { - continueRequest: 'Fetch.continueRequest', - continueResponse: 'Fetch.continueResponse', - disable: 'Fetch.disable', - enable: 'Fetch.enable', - failRequest: 'Fetch.failRequest', - fulfillRequest: 'Fetch.fulfillRequest', - getResponseBody: 'Fetch.getResponseBody', - // Events - requestPaused: 'Fetch.requestPaused', - }, - Emulation: { - clearDeviceMetricsOverride: 'Emulation.clearDeviceMetricsOverride', - setDeviceMetricsOverride: 'Emulation.setDeviceMetricsOverride', - }, - DOMDebugger: { - getEventListeners: 'DOMDebugger.getEventListeners', - }, - Debugger: { - disable: 'Debugger.disable', - enable: 'Debugger.enable', - setSkipAllPauses: 'Debugger.setSkipAllPauses', - // Events - scriptParsed: 'Debugger.scriptParsed', - }, - DOM: { - describeNode: 'DOM.describeNode', - enable: 'DOM.enable', - getBoxModel: 'DOM.getBoxModel', - getDocument: 'DOM.getDocument', - getFrameOwner: 'DOM.getFrameOwner', - pushNodesByBackendIdsToFrontend: 'DOM.pushNodesByBackendIdsToFrontend', - resolveNode: 'DOM.resolveNode', - setInspectedNode: 'DOM.setInspectedNode', - }, - Overlay: { - enable: 'Overlay.enable', - disable: 'Overlay.disable', - hideHighlight: 'Overlay.hideHighlight', - highlightNode: 'Overlay.highlightNode', - setInspectMode: 'Overlay.setInspectMode', - // Events - inspectModeCanceled: 'Overlay.inspectModeCanceled', - inspectNodeRequested: 'Overlay.inspectNodeRequested', - nodeHighlightRequested: 'Overlay.nodeHighlightRequested', - }, - Inspector: { - enable: 'Inspector.enable', - // Events - targetCrashed: 'Inspector.targetCrashed', - targetReloadedAfterCrash: 'Inspector.targetReloadedAfterCrash', - workerScriptLoaded: 'Inspector.workerScriptLoaded', - }, - Network: { - emulateNetworkConditions: 'Network.emulateNetworkConditions', - enable: 'Network.enable', - getRequestPostData: 'Network.getRequestPostData', - getResponseBody: 'Network.getResponseBody', - setBypassServiceWorker: 'Network.setBypassServiceWorker', - setCacheDisabled: 'Network.setCacheDisabled', - // Events - loadingFailed: 'Network.loadingFailed', - loadingFinished: 'Network.loadingFinished', - requestServedFromCache: 'Network.requestServedFromCache', - requestWillBeSent: 'Network.requestWillBeSent', - requestWillBeSentExtraInfo: 'Network.requestWillBeSentExtraInfo', - responseReceived: 'Network.responseReceived', - responseReceivedExtraInfo: 'Network.responseReceivedExtraInfo', - webSocketClosed: 'Network.webSocketClosed', - webSocketCreated: 'Network.webSocketCreated', - webSocketFrameError: 'Network.webSocketFrameError', - webSocketFrameReceived: 'Network.webSocketFrameReceived', - webSocketFrameSent: 'Network.webSocketFrameSent', - webSocketHandshakeResponseReceived: 'Network.webSocketHandshakeResponseReceived', - webSocketWillSendHandshakeRequest: 'Network.webSocketWillSendHandshakeRequest', - }, - Page: { - addScriptToEvaluateOnNewDocument: 'Page.addScriptToEvaluateOnNewDocument', - captureScreenshot: 'Page.captureScreenshot', - createIsolatedWorld: 'Page.createIsolatedWorld', - enable: 'Page.enable', - getFrameTree: 'Page.getFrameTree', - getLayoutMetrics: 'Page.getLayoutMetrics', - getResourceContent: 'Page.getResourceContent', - removeScriptToEvaluateOnNewDocument: 'Page.removeScriptToEvaluateOnNewDocument', - setBypassCSP: 'Page.setBypassCSP', - // Events - frameAttached: 'Page.frameAttached', - frameDetached: 'Page.frameDetached', - frameNavigated: 'Page.frameNavigated', - frameStoppedLoading: 'Page.frameStoppedLoading', - }, - Runtime: { - addBinding: 'Runtime.addBinding', - removeBinding: 'Runtime.removeBinding', - disable: 'Runtime.disable', - discardConsoleEntries: 'Runtime.discardConsoleEntries', - enable: 'Runtime.enable', - callFunctionOn: 'Runtime.callFunctionOn', - evaluate: 'Runtime.evaluate', - getProperties: 'Runtime.getProperties', - queryObjects: 'Runtime.queryObjects', - releaseObjectGroup: 'Runtime.releaseObjectGroup', - runIfWaitingForDebugger: 'Runtime.runIfWaitingForDebugger', - // Events - bindingCalled: 'Runtime.bindingCalled', - consoleAPICalled: 'Runtime.consoleAPICalled', - exceptionThrown: 'Runtime.exceptionThrown', - executionContextCreated: 'Runtime.executionContextCreated', - executionContextDestroyed: 'Runtime.executionContextDestroyed', - executionContextsCleared: 'Runtime.executionContextsCleared', - }, - Log: { - clear: 'Log.clear', - disable: 'Log.disable', - enable: 'Log.enable', - // Events - entryAdded: 'Log.entryAdded', - }, - ServiceWorker: { - disable: 'ServiceWorker.disable', - enable: 'ServiceWorker.enable', - unregister: 'ServiceWorker.unregister', - // Events - workerRegistrationUpdated: 'ServiceWorker.workerRegistrationUpdated', - workerVersionUpdated: 'ServiceWorker.workerVersionUpdated', - }, - Target: { - attachToTarget: 'Target.attachToTarget', - detachFromTarget: 'Target.detachFromTarget', - getTargetInfo: 'Target.getTargetInfo', - setAutoAttach: 'Target.setAutoAttach', - setDiscoverTargets: 'Target.setDiscoverTargets', - // Events - attachedToTarget: 'Target.attachedToTarget', - detachedFromTarget: 'Target.detachedFromTarget', - targetCreated: 'Target.targetCreated', - targetDestroyed: 'Target.targetDestroyed', - }, -} as const; +export const CDP = { ...CDP_DOMAINS, ...CDP_TARGETS } as const; diff --git a/src/main/engine/constants/cdpDomains.ts b/src/main/engine/constants/cdpDomains.ts new file mode 100644 index 0000000..8f9c9ad --- /dev/null +++ b/src/main/engine/constants/cdpDomains.ts @@ -0,0 +1,56 @@ +/** CDP's interception, emulation, debugging and DOM domains, as the protocol spells their commands and events. */ +export const CDP_DOMAINS = { + Fetch: { + continueRequest: 'Fetch.continueRequest', + continueResponse: 'Fetch.continueResponse', + disable: 'Fetch.disable', + enable: 'Fetch.enable', + failRequest: 'Fetch.failRequest', + fulfillRequest: 'Fetch.fulfillRequest', + getResponseBody: 'Fetch.getResponseBody', + // Events + requestPaused: 'Fetch.requestPaused', + }, + Emulation: { + clearDeviceMetricsOverride: 'Emulation.clearDeviceMetricsOverride', + setDeviceMetricsOverride: 'Emulation.setDeviceMetricsOverride', + }, + DOMDebugger: { + getEventListeners: 'DOMDebugger.getEventListeners', + }, + Debugger: { + disable: 'Debugger.disable', + enable: 'Debugger.enable', + setSkipAllPauses: 'Debugger.setSkipAllPauses', + // Events + scriptParsed: 'Debugger.scriptParsed', + }, + DOM: { + describeNode: 'DOM.describeNode', + enable: 'DOM.enable', + getBoxModel: 'DOM.getBoxModel', + getDocument: 'DOM.getDocument', + getFrameOwner: 'DOM.getFrameOwner', + pushNodesByBackendIdsToFrontend: 'DOM.pushNodesByBackendIdsToFrontend', + resolveNode: 'DOM.resolveNode', + setInspectedNode: 'DOM.setInspectedNode', + }, + Overlay: { + enable: 'Overlay.enable', + disable: 'Overlay.disable', + hideHighlight: 'Overlay.hideHighlight', + highlightNode: 'Overlay.highlightNode', + setInspectMode: 'Overlay.setInspectMode', + // Events + inspectModeCanceled: 'Overlay.inspectModeCanceled', + inspectNodeRequested: 'Overlay.inspectNodeRequested', + nodeHighlightRequested: 'Overlay.nodeHighlightRequested', + }, + Inspector: { + enable: 'Inspector.enable', + // Events + targetCrashed: 'Inspector.targetCrashed', + targetReloadedAfterCrash: 'Inspector.targetReloadedAfterCrash', + workerScriptLoaded: 'Inspector.workerScriptLoaded', + }, +} as const; diff --git a/src/main/engine/constants/cdpTargets.ts b/src/main/engine/constants/cdpTargets.ts new file mode 100644 index 0000000..20e6a64 --- /dev/null +++ b/src/main/engine/constants/cdpTargets.ts @@ -0,0 +1,100 @@ +/** CDP's network, page, runtime, log, service worker, target and browser domains, as the protocol spells their commands and events. */ +export const CDP_TARGETS = { + Network: { + emulateNetworkConditions: 'Network.emulateNetworkConditions', + enable: 'Network.enable', + getRequestPostData: 'Network.getRequestPostData', + getResponseBody: 'Network.getResponseBody', + setBypassServiceWorker: 'Network.setBypassServiceWorker', + setCacheDisabled: 'Network.setCacheDisabled', + // Events + loadingFailed: 'Network.loadingFailed', + loadingFinished: 'Network.loadingFinished', + requestServedFromCache: 'Network.requestServedFromCache', + requestWillBeSent: 'Network.requestWillBeSent', + requestWillBeSentExtraInfo: 'Network.requestWillBeSentExtraInfo', + responseReceived: 'Network.responseReceived', + responseReceivedExtraInfo: 'Network.responseReceivedExtraInfo', + webSocketClosed: 'Network.webSocketClosed', + webSocketCreated: 'Network.webSocketCreated', + webSocketFrameError: 'Network.webSocketFrameError', + webSocketFrameReceived: 'Network.webSocketFrameReceived', + webSocketFrameSent: 'Network.webSocketFrameSent', + webSocketHandshakeResponseReceived: 'Network.webSocketHandshakeResponseReceived', + webSocketWillSendHandshakeRequest: 'Network.webSocketWillSendHandshakeRequest', + }, + Page: { + addScriptToEvaluateOnNewDocument: 'Page.addScriptToEvaluateOnNewDocument', + captureScreenshot: 'Page.captureScreenshot', + createIsolatedWorld: 'Page.createIsolatedWorld', + enable: 'Page.enable', + getFrameTree: 'Page.getFrameTree', + getLayoutMetrics: 'Page.getLayoutMetrics', + navigate: 'Page.navigate', + reload: 'Page.reload', + getResourceContent: 'Page.getResourceContent', + removeScriptToEvaluateOnNewDocument: 'Page.removeScriptToEvaluateOnNewDocument', + setBypassCSP: 'Page.setBypassCSP', + // Events + frameAttached: 'Page.frameAttached', + frameDetached: 'Page.frameDetached', + frameNavigated: 'Page.frameNavigated', + frameStoppedLoading: 'Page.frameStoppedLoading', + loadEventFired: 'Page.loadEventFired', + }, + Runtime: { + addBinding: 'Runtime.addBinding', + removeBinding: 'Runtime.removeBinding', + disable: 'Runtime.disable', + discardConsoleEntries: 'Runtime.discardConsoleEntries', + enable: 'Runtime.enable', + callFunctionOn: 'Runtime.callFunctionOn', + evaluate: 'Runtime.evaluate', + getProperties: 'Runtime.getProperties', + queryObjects: 'Runtime.queryObjects', + releaseObjectGroup: 'Runtime.releaseObjectGroup', + runIfWaitingForDebugger: 'Runtime.runIfWaitingForDebugger', + // Events + bindingCalled: 'Runtime.bindingCalled', + consoleAPICalled: 'Runtime.consoleAPICalled', + exceptionThrown: 'Runtime.exceptionThrown', + executionContextCreated: 'Runtime.executionContextCreated', + executionContextDestroyed: 'Runtime.executionContextDestroyed', + executionContextsCleared: 'Runtime.executionContextsCleared', + }, + Log: { + clear: 'Log.clear', + disable: 'Log.disable', + enable: 'Log.enable', + // Events + entryAdded: 'Log.entryAdded', + }, + ServiceWorker: { + disable: 'ServiceWorker.disable', + enable: 'ServiceWorker.enable', + unregister: 'ServiceWorker.unregister', + // Events + workerRegistrationUpdated: 'ServiceWorker.workerRegistrationUpdated', + workerVersionUpdated: 'ServiceWorker.workerVersionUpdated', + }, + Target: { + attachToTarget: 'Target.attachToTarget', + detachFromTarget: 'Target.detachFromTarget', + getTargetInfo: 'Target.getTargetInfo', + setAutoAttach: 'Target.setAutoAttach', + setDiscoverTargets: 'Target.setDiscoverTargets', + activateTarget: 'Target.activateTarget', + closeTarget: 'Target.closeTarget', + createTarget: 'Target.createTarget', + getTargets: 'Target.getTargets', + // Events + attachedToTarget: 'Target.attachedToTarget', + detachedFromTarget: 'Target.detachedFromTarget', + targetCreated: 'Target.targetCreated', + targetDestroyed: 'Target.targetDestroyed', + targetInfoChanged: 'Target.targetInfoChanged', + }, + Browser: { + getVersion: 'Browser.getVersion', + }, +} as const; diff --git a/src/main/engine/websocketTransport/CdpConnection.ts b/src/main/engine/websocketTransport/CdpConnection.ts index 707dc89..09a1220 100644 --- a/src/main/engine/websocketTransport/CdpConnection.ts +++ b/src/main/engine/websocketTransport/CdpConnection.ts @@ -5,19 +5,21 @@ import type { Pending, RawHandler } from './types'; * A browser-level Chrome DevTools Protocol connection over WebSocket * (`ws://…/devtools/browser/`), using flattened sessions. * - * Used by the tests to drive a real Chromium, and the building block for - * driving an external Chrome (roadmap M3). + * How the app drives a Chromium browser it launched (`browsers/driven`), and + * how the tests drive a real Chromium. */ export class CdpConnection { private nextId = 1; private readonly pending = new Map(); private readonly handlers = new Set(); + private readonly closeHandlers = new Set<() => void>(); private constructor(private readonly ws: WebSocket) { ws.addEventListener('message', (event) => this.onMessage(String(event.data))); ws.addEventListener('close', () => { for (const p of this.pending.values()) p.reject(new Error(`CDP connection closed during ${p.method}`)); this.pending.clear(); + for (const h of this.closeHandlers) h(); }); } @@ -42,6 +44,12 @@ export class CdpConnection { return () => this.handlers.delete(handler); } + /** Called once the connection has closed: closed here, or the browser went away. */ + onClose(handler: () => void): () => void { + this.closeHandlers.add(handler); + return () => this.closeHandlers.delete(handler); + } + close(): void { this.ws.close(); } diff --git a/src/main/engine/websocketTransport/attachToPage.ts b/src/main/engine/websocketTransport/attachToPage.ts index c0b2527..9c2bbf4 100644 --- a/src/main/engine/websocketTransport/attachToPage.ts +++ b/src/main/engine/websocketTransport/attachToPage.ts @@ -1,51 +1,14 @@ -import type { CdpTransport } from '../cdp'; import { CDP } from '../constants'; import type { CdpConnection } from './CdpConnection'; +import { pageTransport } from './pageTransport'; +import type { PageTransport } from './types'; /** * Attaches to a page target and returns a transport whose root session is that * page. Child sessions (auto-attached iframes) are addressed by their sessionId; * their events carry it, while the page's own events carry none. */ -export async function attachToPage( - connection: CdpConnection, - targetId: string, -): Promise }> { - const { sessionId: root } = await connection.send<{ sessionId: string }>(CDP.Target.attachToTarget, { targetId, flatten: true }); - const handlers = new Map void>>(); - // Sessions of this page: its own plus iframe sessions auto-attached below it. - // Other pages on the same connection are never forwarded. - const parentOf = new Map([[root, '']]); - const forget = (sessionId: string) => { - parentOf.delete(sessionId); - for (const [child, parent] of [...parentOf]) if (parent === sessionId) forget(child); - }; - const off = connection.onEvent((method, params, sessionId) => { - if (!sessionId || !parentOf.has(sessionId)) return; - const p = params as { sessionId?: string }; - if (method === CDP.Target.attachedToTarget && p.sessionId) parentOf.set(p.sessionId, sessionId); - for (const h of handlers.get(method) ?? []) { - // Each handler on its own: one that throws must not skip the others (or the session bookkeeping below). - try { - h(params, sessionId === root ? undefined : sessionId); - } catch (err) { - console.error('CDP event handler failed', method, err); - } - } - if (method === CDP.Target.detachedFromTarget && p.sessionId) forget(p.sessionId); - }); - return { - sessionId: root, - send: (method, params, sessionId) => connection.send(method, params, sessionId ?? root), - on(event, handler) { - if (!handlers.has(event)) handlers.set(event, new Set()); - handlers.get(event)!.add(handler); - return () => handlers.get(event)?.delete(handler); - }, - async detach() { - off(); - handlers.clear(); - await connection.send(CDP.Target.detachFromTarget, { sessionId: root }).catch(() => undefined); - }, - }; +export async function attachToPage(connection: CdpConnection, targetId: string): Promise { + const { sessionId } = await connection.send<{ sessionId: string }>(CDP.Target.attachToTarget, { targetId, flatten: true }); + return pageTransport(connection, sessionId); } diff --git a/src/main/engine/websocketTransport/index.ts b/src/main/engine/websocketTransport/index.ts index 5c7cc1d..a44c1c8 100644 --- a/src/main/engine/websocketTransport/index.ts +++ b/src/main/engine/websocketTransport/index.ts @@ -1,2 +1,4 @@ export { attachToPage } from './attachToPage'; export { CdpConnection } from './CdpConnection'; +export { pageTransport } from './pageTransport'; +export type { PageTransport } from './types'; diff --git a/src/main/engine/websocketTransport/pageTransport.ts b/src/main/engine/websocketTransport/pageTransport.ts new file mode 100644 index 0000000..e6d068e --- /dev/null +++ b/src/main/engine/websocketTransport/pageTransport.ts @@ -0,0 +1,47 @@ +import { CDP } from '../constants'; +import type { CdpConnection } from './CdpConnection'; +import type { PageTransport } from './types'; + +/** + * A transport whose root session is a page's, already attached (`root`): the page's own events carry no session id, + * its child sessions' (auto-attached iframes and workers) carry theirs, and other pages on the connection are never + * forwarded. `detach` detaches the page's session. + */ +export function pageTransport(connection: CdpConnection, root: string): PageTransport { + const handlers = new Map void>>(); + // Sessions of this page: its own plus iframe sessions auto-attached below it. + // Other pages on the same connection are never forwarded. + const parentOf = new Map([[root, '']]); + const forget = (sessionId: string) => { + parentOf.delete(sessionId); + for (const [child, parent] of [...parentOf]) if (parent === sessionId) forget(child); + }; + const off = connection.onEvent((method, params, sessionId) => { + if (!sessionId || !parentOf.has(sessionId)) return; + const p = params as { sessionId?: string }; + if (method === CDP.Target.attachedToTarget && p.sessionId) parentOf.set(p.sessionId, sessionId); + for (const h of handlers.get(method) ?? []) { + // Each handler on its own: one that throws must not skip the others (or the session bookkeeping below). + try { + h(params, sessionId === root ? undefined : sessionId); + } catch (err) { + console.error('CDP event handler failed', method, err); + } + } + if (method === CDP.Target.detachedFromTarget && p.sessionId) forget(p.sessionId); + }); + return { + sessionId: root, + send: (method, params, sessionId) => connection.send(method, params, sessionId ?? root), + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event)!.add(handler); + return () => handlers.get(event)?.delete(handler); + }, + async detach() { + off(); + handlers.clear(); + await connection.send(CDP.Target.detachFromTarget, { sessionId: root }).catch(() => undefined); + }, + }; +} diff --git a/src/main/engine/websocketTransport/types.ts b/src/main/engine/websocketTransport/types.ts index 561c298..b1ea16b 100644 --- a/src/main/engine/websocketTransport/types.ts +++ b/src/main/engine/websocketTransport/types.ts @@ -1,3 +1,5 @@ +import type { CdpTransport } from '../cdp'; + export interface Pending { resolve(value: unknown): void; reject(error: Error): void; @@ -6,3 +8,6 @@ export interface Pending { } export type RawHandler = (method: string, params: unknown, sessionId: string | undefined) => void; + +/** A page's session on a browser-level connection, as a transport rooted at it. */ +export type PageTransport = CdpTransport & { readonly sessionId: string; detach(): Promise }; diff --git a/src/main/ipc/registerBrowserIpc.ts b/src/main/ipc/registerBrowserIpc.ts index b44a669..65af4dc 100644 --- a/src/main/ipc/registerBrowserIpc.ts +++ b/src/main/ipc/registerBrowserIpc.ts @@ -1,6 +1,6 @@ import { dialog, type BrowserWindow, type OpenDialogOptions } from 'electron'; import { IPC_CHANNEL } from '../../shared/ipcChannels'; -import type { BrowserRegistry } from '../browsers'; +import type { BrowserRegistry, DrivenBrowsers } from '../browsers'; import { assertString } from './assertString'; import type { IpcHandle } from './types'; @@ -11,17 +11,38 @@ const ADD_BROWSER_DIALOG: Partial browsers.list()); handlePage(IPC_CHANNEL.openInBrowser, (id: unknown, url: unknown) => { assertString(id, 'id'); assertString(url, 'url'); return browsers.open(id, url); }); + handlePage(IPC_CHANNEL.openWithChanges, (id: unknown, url: unknown) => { + assertString(id, 'id'); + assertString(url, 'url'); + return driven.open(id, url); + }); + handlePage(IPC_CHANNEL.listDriven, () => driven.read()); + handlePage(IPC_CHANNEL.activateTab, (browserId: unknown, tabId: unknown) => { + assertString(browserId, 'browserId'); + assertString(tabId, 'tabId'); + return driven.activate(browserId, tabId); + }); + handlePage(IPC_CHANNEL.stopDriving, (browserId: unknown) => { + assertString(browserId, 'browserId'); + driven.stop(browserId); + }); handle(IPC_CHANNEL.addBrowser, async () => { const { canceled, filePaths } = await dialog.showOpenDialog(win, { title: 'Add a browser', properties: ['openFile'], ...ADD_BROWSER_DIALOG[process.platform] }); return canceled || !filePaths[0] ? null : browsers.add(filePaths[0]); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index b950883..19e069d 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -19,7 +19,7 @@ import { registerShotIpc } from './registerShotIpc'; import { registerSourceMapIpc } from './registerSourceMapIpc'; import type { IpcDeps } from './types'; -export function registerIpc({ win, page, store, rules, settings, session, actions, sourceMaps, actionsWindow, workspaces, browsers, shots, shotStore, updates, send, onSessionFlushed }: IpcDeps): void { +export function registerIpc({ win, page, store, rules, settings, session, actions, sourceMaps, actionsWindow, workspaces, browsers, driven, shots, shotStore, updates, send, onSessionFlushed }: IpcDeps): void { // Only the editor UI may call these (the website view has no preload, but be strict anyway). const fromEditor = (event: IpcMainInvokeEvent | IpcMainEvent) => event.sender.id === win.webContents.id; @@ -87,8 +87,8 @@ export function registerIpc({ win, page, store, rules, settings, session, action registerNetworkIpc(handle, page.network); registerHarIpc(handle, { win, page, store }); registerOverridesFileIpc(handle, { win, page, store, rules }); - registerBrowserIpc(handle, handlePage, { win, browsers }); - registerShotIpc(handle, handlePage, { win, shots, store: shotStore, send }); + registerBrowserIpc(handle, handlePage, { win, browsers, driven }); + registerShotIpc(handle, handlePage, { win, shots, store: shotStore, driven, send }); registerOverlayIpc(handlePage, shots.overlay); handle(IPC_CHANNEL.getSession, () => session.get()); diff --git a/src/main/ipc/registerShotIpc.ts b/src/main/ipc/registerShotIpc.ts index 4df69d4..097080c 100644 --- a/src/main/ipc/registerShotIpc.ts +++ b/src/main/ipc/registerShotIpc.ts @@ -2,8 +2,9 @@ import { basename } from 'node:path'; import { copyFile } from 'node:fs/promises'; import { BrowserWindow, dialog, shell, type BrowserWindow as Window } from 'electron'; import { IPC_CHANNEL } from '../../shared/ipcChannels'; -import type { AppEvent } from '../../shared/types'; +import type { AppEvent, CaptureArea } from '../../shared/types'; import { copyShotImage, importDesignFiles, type PageShots } from '../shots'; +import type { DrivenBrowsers } from '../browsers'; import type { ShotStore } from '../store/ShotStore'; import { assertString } from './assertString'; import type { IpcHandle } from './types'; @@ -15,6 +16,7 @@ interface ShotIpcDeps { win: Window; shots: PageShots; store: ShotStore; + driven: DrivenBrowsers; send(event: AppEvent): void; } @@ -22,10 +24,16 @@ interface ShotIpcDeps { * Captures and designs' channels. The shots menu is in both windows' toolbars (`handlePage`); capturing a picked * element is the Component page's, in the editor. */ -export function registerShotIpc(handle: IpcHandle, handlePage: IpcHandle, { win, shots, store, send }: ShotIpcDeps): void { +export function registerShotIpc(handle: IpcHandle, handlePage: IpcHandle, { win, shots, store, driven, send }: ShotIpcDeps): void { handlePage(IPC_CHANNEL.listShots, () => shots.list()); handlePage(IPC_CHANNEL.captureShot, (area: unknown) => shots.capture(area)); handle(IPC_CHANNEL.captureElementShot, (pickId: unknown) => shots.captureElement(pickId)); + handlePage(IPC_CHANNEL.captureTabShot, async (browserId: unknown, tabId: unknown, area: unknown) => { + assertString(browserId, 'browserId'); + assertString(tabId, 'tabId'); + const { image, url, browser } = await driven.capture(browserId, tabId, area); + return shots.keep(image, url, area as CaptureArea, browser); + }); handlePage(IPC_CHANNEL.readShot, (id: unknown) => store.read(id)); handlePage(IPC_CHANNEL.renameShot, (id: unknown, name: unknown) => shots.rename(id, name)); handlePage(IPC_CHANNEL.deleteShot, (id: unknown) => shots.remove(id)); diff --git a/src/main/ipc/types.ts b/src/main/ipc/types.ts index 146c57f..ff91ffe 100644 --- a/src/main/ipc/types.ts +++ b/src/main/ipc/types.ts @@ -1,7 +1,7 @@ import type { BrowserWindow } from 'electron'; import type { AppEvent } from '../../shared/types'; import type { ActionsWindow } from '../ActionsWindow'; -import type { BrowserRegistry } from '../browsers'; +import type { BrowserRegistry, DrivenBrowsers } from '../browsers'; import type { PageShots } from '../shots'; import type { PageController } from '../PageController'; import type { ActionStore } from '../store/ActionStore'; @@ -31,6 +31,8 @@ export interface IpcDeps { workspaces: WorkspaceController; /** The other browsers on this computer. */ browsers: BrowserRegistry; + /** The Chromium browsers the app drives, serving the workspace's changes. */ + driven: DrivenBrowsers; /** The workspace's captures and designs, and where they are kept. */ shots: PageShots; shotStore: ShotStore; diff --git a/src/main/launch/createWindow.ts b/src/main/launch/createWindow.ts index c2f0df1..74b4c9b 100644 --- a/src/main/launch/createWindow.ts +++ b/src/main/launch/createWindow.ts @@ -5,7 +5,7 @@ import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { AppEvent } from '../../shared/types'; import { REPO_URL } from '../appInfo'; import { ActionsWindow } from '../ActionsWindow'; -import { BrowserRegistry } from '../browsers'; +import { BrowserRegistry, DrivenBrowsers } from '../browsers'; import { installMenu } from '../installMenu'; import { watchOverrideFiles } from '../overrideFiles'; import { registerIpc } from '../ipc'; @@ -44,6 +44,8 @@ export async function createWindow(updateFeed: string | undefined): Promise send({ type: 'actions-window', state }) }); @@ -53,6 +55,7 @@ export async function createWindow(updateFeed: string | undefined): Promise send({ type: 'error', message: rulesProblem })); const browsers = new BrowserRegistry({ prefs: browserPrefs, send }); + const driven = new DrivenBrowsers({ registry: browsers, sources: { store, rules, settings }, userData, send }); const page = new PageController(win, { store, rules, settings, send, windowStore: pageWindow, breakpoints: () => activeBreakpoints(session) }); launchState.running = { win, page }; // Before the engine attaches: it serves the active workspace's overrides and rules from the start. @@ -76,8 +79,10 @@ export async function createWindow(updateFeed: string | undefined): Promise closing.flushed(ok) }); + registerIpc({ win, page, store, rules, settings, session, actions, sourceMaps, actionsWindow, workspaces, browsers, driven, shots, shotStore, updates, send, onSessionFlushed: (ok) => closing.flushed(ok) }); lockEditorNavigation(win); diff --git a/src/main/shots/PageShots/PageShots.ts b/src/main/shots/PageShots/PageShots.ts index 67d4ad8..c6eaedc 100644 --- a/src/main/shots/PageShots/PageShots.ts +++ b/src/main/shots/PageShots/PageShots.ts @@ -1,7 +1,7 @@ import type { CaptureArea, Shot, ShotBrowser } from '../../../shared/types'; import { atWidth, captureOverCdp, type CapturedImage, type CaptureTarget } from '../capture'; import { captureName } from '../captureName'; -import { APP_BROWSER, CAPTURE_FORMAT, DESIGN_NAME } from '../constants'; +import { APP_BROWSER, CAPTURE_FORMAT, DESIGN_NAME, PAGE_AREAS } from '../constants'; import { designScale } from '../designScale'; import { imageInfo } from '../imageInfo'; import { DesignOverlay } from '../../overlay'; @@ -9,9 +9,6 @@ import { makeThumbnail } from '../makeThumbnail'; import { waitUntilShown } from '../waitUntilShown'; import type { PageShotsDeps } from './types'; -/** The capture areas asked of the page as a whole (an element's goes through its pick). */ -const PAGE_AREAS: ReadonlySet = new Set(['viewport', 'page']); - /** * The workspace's captures and designs: taking captures of the app's page (with the design laid over it taken off * while they are), keeping them, and every change announced as `shots-changed` (the active workspace's, newest first). diff --git a/src/main/shots/constants.ts b/src/main/shots/constants.ts index 427cdc0..e703666 100644 --- a/src/main/shots/constants.ts +++ b/src/main/shots/constants.ts @@ -28,6 +28,9 @@ export const NAME_UNSAFE = /[^a-z0-9._-]+/gi; /** The longest part of a name made from an address. */ export const MAX_NAME_STEM = 80; +/** The capture areas asked of a page as a whole (an element's goes through its pick). */ +export const PAGE_AREAS: ReadonlySet = new Set(['viewport', 'page']); + /** The app's own page, as the browser a capture was taken in. */ export const APP_BROWSER = { id: 'app', name: 'Chromium' } as const; diff --git a/src/preload/browsersApi.ts b/src/preload/browsersApi.ts index 7981baa..5381648 100644 --- a/src/preload/browsersApi.ts +++ b/src/preload/browsersApi.ts @@ -9,4 +9,8 @@ export const browsersApi: BrowsersApi = { addBrowser: () => ipcRenderer.invoke(IPC_CHANNEL.addBrowser), removeBrowser: (id) => ipcRenderer.invoke(IPC_CHANNEL.removeBrowser, id), setBrowserHidden: (id, hidden) => ipcRenderer.invoke(IPC_CHANNEL.setBrowserHidden, id, hidden), + openWithChanges: (id, url) => ipcRenderer.invoke(IPC_CHANNEL.openWithChanges, id, url), + listDriven: () => ipcRenderer.invoke(IPC_CHANNEL.listDriven), + activateTab: (browserId, tabId) => ipcRenderer.invoke(IPC_CHANNEL.activateTab, browserId, tabId), + stopDriving: (browserId) => ipcRenderer.invoke(IPC_CHANNEL.stopDriving, browserId), }; diff --git a/src/preload/shotsApi.ts b/src/preload/shotsApi.ts index 1d33a3a..6073a55 100644 --- a/src/preload/shotsApi.ts +++ b/src/preload/shotsApi.ts @@ -7,6 +7,7 @@ export const shotsApi: ShotsApi = { listShots: () => ipcRenderer.invoke(IPC_CHANNEL.listShots), captureShot: (area) => ipcRenderer.invoke(IPC_CHANNEL.captureShot, area), captureElementShot: (pickId) => ipcRenderer.invoke(IPC_CHANNEL.captureElementShot, pickId), + captureTabShot: (browserId, tabId, area) => ipcRenderer.invoke(IPC_CHANNEL.captureTabShot, browserId, tabId, area), readShot: (id) => ipcRenderer.invoke(IPC_CHANNEL.readShot, id), renameShot: (id, name) => ipcRenderer.invoke(IPC_CHANNEL.renameShot, id, name), deleteShot: (id) => ipcRenderer.invoke(IPC_CHANNEL.deleteShot, id), diff --git a/src/renderer/src/app/model/bridge/appEventHandlers.ts b/src/renderer/src/app/model/bridge/appEventHandlers.ts index 3c35591..38d7663 100644 --- a/src/renderer/src/app/model/bridge/appEventHandlers.ts +++ b/src/renderer/src/app/model/bridge/appEventHandlers.ts @@ -72,6 +72,7 @@ export const APP_EVENT_HANDLERS: AppEventHandlers = { 'actions-window': (event) => useActionStore.getState().setWindow(event.state), 'settings-changed': (event) => useSettingsStore.getState().setSettings(event.settings), 'browsers-changed': (event) => useBrowserStore.getState().setAll(event.browsers), + 'driven-browsers-changed': (event) => useBrowserStore.getState().setDriven(event.driven), 'shots-changed': (event) => receiveShots(event.shots), 'show-shot': (event) => showShotById(event.id), 'overlay-changed': (event) => useShotStore.getState().setOverlay(event.overlay), diff --git a/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts b/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts index a03201c..ee4a2f0 100644 --- a/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts +++ b/src/renderer/src/app/model/page-window-bridge/pageWindowEventHandlers.ts @@ -13,6 +13,7 @@ export const PAGE_WINDOW_EVENT_HANDLERS: PageWindowEventHandlers = { 'page-state': (event) => usePageStore.getState().setPage(event.state), command: (event) => PAGE_WINDOW_COMMANDS[event.command]?.(), 'browsers-changed': (event) => useBrowserStore.getState().setAll(event.browsers), + 'driven-browsers-changed': (event) => useBrowserStore.getState().setDriven(event.driven), 'shots-changed': (event) => useShotStore.getState().setAll(event.shots), 'overlay-changed': (event) => useShotStore.getState().setOverlay(event.overlay), }; diff --git a/src/renderer/src/entities/browser/model/store/types.ts b/src/renderer/src/entities/browser/model/store/types.ts index 7ebb1fa..41c499d 100644 --- a/src/renderer/src/entities/browser/model/store/types.ts +++ b/src/renderer/src/entities/browser/model/store/types.ts @@ -1,10 +1,13 @@ -import type { BrowserInfo } from '@common/types'; +import type { BrowserInfo, DrivenBrowser } from '@common/types'; export interface BrowserStore { /** The browsers on this computer and the ones the user added, hidden ones too. */ browsers: BrowserInfo[]; /** Whether they have been looked for yet (the menu shows a spinner until then). */ loaded: boolean; + /** The Chromium browsers the app drives with the workspace's changes, with their tabs. */ + driven: DrivenBrowser[]; setAll(browsers: BrowserInfo[]): void; + setDriven(driven: DrivenBrowser[]): void; } diff --git a/src/renderer/src/entities/browser/model/store/useBrowserStore.ts b/src/renderer/src/entities/browser/model/store/useBrowserStore.ts index 53f4a68..2eb565d 100644 --- a/src/renderer/src/entities/browser/model/store/useBrowserStore.ts +++ b/src/renderer/src/entities/browser/model/store/useBrowserStore.ts @@ -5,5 +5,7 @@ import type { BrowserStore } from './types'; export const useBrowserStore = create()((set) => ({ browsers: [], loaded: false, + driven: [], setAll: (browsers) => set({ browsers, loaded: true }), + setDriven: (driven) => set({ driven }), })); diff --git a/src/renderer/src/entities/shot/lib/matchesShot.ts b/src/renderer/src/entities/shot/lib/matchesShot.ts index 2f73119..4c6abe3 100644 --- a/src/renderer/src/entities/shot/lib/matchesShot.ts +++ b/src/renderer/src/entities/shot/lib/matchesShot.ts @@ -1,10 +1,7 @@ import type { Shot } from '@common/types'; +import { matchesWords } from '@/shared/lib'; /** Whether a shot answers a search: every word in its name, page address or browser. */ export function matchesShot(shot: Shot, query: string): boolean { - const text = `${shot.name} ${shot.pageUrl ?? ''} ${shot.browser?.name ?? ''} ${shot.kind}`.toLowerCase(); - return query - .toLowerCase() - .split(/\s+/) - .every((word) => text.includes(word)); + return matchesWords(`${shot.name} ${shot.pageUrl ?? ''} ${shot.browser?.name ?? ''} ${shot.kind}`, query); } diff --git a/src/renderer/src/features/browser/open-in-browser/index.ts b/src/renderer/src/features/browser/open-in-browser/index.ts index 3e2de87..8d1deef 100644 --- a/src/renderer/src/features/browser/open-in-browser/index.ts +++ b/src/renderer/src/features/browser/open-in-browser/index.ts @@ -1,2 +1,2 @@ -export { loadBrowsers, openInBrowser } from './model'; +export { loadBrowsers, openInBrowser, openWithChanges } from './model'; export { BrowserMenuButton } from './ui/BrowserMenuButton'; diff --git a/src/renderer/src/features/browser/open-in-browser/model/activateTab.ts b/src/renderer/src/features/browser/open-in-browser/model/activateTab.ts new file mode 100644 index 0000000..569be44 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/activateTab.ts @@ -0,0 +1,12 @@ +import type { DrivenBrowser, DrivenTab } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Brings a driven browser's tab to the front. */ +export async function activateTab(browser: DrivenBrowser, tab: DrivenTab): Promise { + try { + await api.activateTab(browser.id, tab.id); + } catch (err) { + toast({ title: `Could not show the tab in ${browser.name}`, description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/open-in-browser/model/captureTab.ts b/src/renderer/src/features/browser/open-in-browser/model/captureTab.ts new file mode 100644 index 0000000..cacf3ad --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/captureTab.ts @@ -0,0 +1,13 @@ +import type { DrivenBrowser, DrivenTab } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Captures what a driven browser's tab shows; it joins the shots (announced). */ +export async function captureTab(browser: DrivenBrowser, tab: DrivenTab): Promise { + try { + const shot = await api.captureTabShot(browser.id, tab.id, 'viewport'); + toast({ title: `Captured the page in ${browser.name}`, description: shot.name, tone: 'success' }); + } catch (err) { + toast({ title: `Could not capture the page in ${browser.name}`, description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/open-in-browser/model/index.ts b/src/renderer/src/features/browser/open-in-browser/model/index.ts index 0b1d439..2949832 100644 --- a/src/renderer/src/features/browser/open-in-browser/model/index.ts +++ b/src/renderer/src/features/browser/open-in-browser/model/index.ts @@ -1,3 +1,9 @@ +export { activateTab } from './activateTab'; +export { captureTab } from './captureTab'; export { loadBrowsers } from './loadBrowsers'; export { matchesBrowser } from './matchesBrowser'; +export { matchesTab } from './matchesTab'; export { openInBrowser } from './openInBrowser'; +export { openTabHere } from './openTabHere'; +export { openWithChanges } from './openWithChanges'; +export { stopDriving } from './stopDriving'; diff --git a/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts b/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts index 38341f6..0de5e85 100644 --- a/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts +++ b/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts @@ -1,8 +1,13 @@ import { api } from '@/shared/api'; import { useBrowserStore } from '@/entities/browser'; -/** Asks for the browsers (the main process looks again when its list is a minute old); a failure leaves the list as it was, looked for. */ +/** + * Asks for the browsers (the main process looks again when its list is a minute old) and the ones driven with the + * workspace's changes; a failure leaves the lists as they were, the browsers looked for. + */ export async function loadBrowsers(): Promise { - const { setAll } = useBrowserStore.getState(); - setAll(await api.listBrowsers().catch(() => useBrowserStore.getState().browsers)); + const { setAll, setDriven } = useBrowserStore.getState(); + const [browsers, driven] = await Promise.all([api.listBrowsers().catch(() => useBrowserStore.getState().browsers), api.listDriven().catch(() => useBrowserStore.getState().driven)]); + setAll(browsers); + setDriven(driven); } diff --git a/src/renderer/src/features/browser/open-in-browser/model/matchesBrowser.ts b/src/renderer/src/features/browser/open-in-browser/model/matchesBrowser.ts index c63b9c7..408c807 100644 --- a/src/renderer/src/features/browser/open-in-browser/model/matchesBrowser.ts +++ b/src/renderer/src/features/browser/open-in-browser/model/matchesBrowser.ts @@ -1,10 +1,7 @@ import type { BrowserInfo } from '@common/types'; +import { matchesWords } from '@/shared/lib'; /** Whether a browser answers the menu's search: every word in its name, engine or version. */ export function matchesBrowser(browser: BrowserInfo, query: string): boolean { - const text = `${browser.name} ${browser.engine} ${browser.version ?? ''}`.toLowerCase(); - return query - .toLowerCase() - .split(/\s+/) - .every((word) => text.includes(word)); + return matchesWords(`${browser.name} ${browser.engine} ${browser.version ?? ''}`, query); } diff --git a/src/renderer/src/features/browser/open-in-browser/model/matchesTab.ts b/src/renderer/src/features/browser/open-in-browser/model/matchesTab.ts new file mode 100644 index 0000000..a7b84c5 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/matchesTab.ts @@ -0,0 +1,7 @@ +import type { DrivenTab } from '@common/types'; +import { matchesWords } from '@/shared/lib'; + +/** Whether a driven tab answers the menu's search: every word in its title or address. */ +export function matchesTab(tab: DrivenTab, query: string): boolean { + return matchesWords(`${tab.title} ${tab.url}`, query); +} diff --git a/src/renderer/src/features/browser/open-in-browser/model/openTabHere.ts b/src/renderer/src/features/browser/open-in-browser/model/openTabHere.ts new file mode 100644 index 0000000..be0a2b3 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/openTabHere.ts @@ -0,0 +1,12 @@ +import type { DrivenTab } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Loads a driven tab's address in the app's own page. */ +export async function openTabHere(tab: DrivenTab): Promise { + try { + await api.navigate(tab.url); + } catch (err) { + toast({ title: 'Could not open the page here', description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/open-in-browser/model/openWithChanges.ts b/src/renderer/src/features/browser/open-in-browser/model/openWithChanges.ts new file mode 100644 index 0000000..4c2a951 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/openWithChanges.ts @@ -0,0 +1,13 @@ +import type { BrowserInfo } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; +import { usePageStore } from '@/entities/page'; + +/** Opens the page shown in a Chromium browser with a profile of the app's own, served the workspace's overrides and rules. */ +export async function openWithChanges(browser: Pick): Promise { + try { + await api.openWithChanges(browser.id, usePageStore.getState().page.url); + } catch (err) { + toast({ title: `Could not open ${browser.name} with your changes`, description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/open-in-browser/model/stopDriving.ts b/src/renderer/src/features/browser/open-in-browser/model/stopDriving.ts new file mode 100644 index 0000000..284b092 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/stopDriving.ts @@ -0,0 +1,12 @@ +import type { DrivenBrowser } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** Stops serving the workspace's changes in a driven browser; it stays open. */ +export async function stopDriving(browser: DrivenBrowser): Promise { + try { + await api.stopDriving(browser.id); + } catch (err) { + toast({ title: `Could not let go of ${browser.name}`, description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx index a84b4d8..a3efb93 100644 --- a/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx @@ -9,8 +9,9 @@ import { Input } from '@/shared/ui/input'; import { Spinner } from '@/shared/ui/spinner'; import { selectShownBrowsers, useBrowserStore } from '@/entities/browser'; import { usePageStore } from '@/entities/page'; -import { matchesBrowser, openInBrowser } from '../model'; +import { matchesBrowser, openInBrowser, openWithChanges } from '../model'; import { BrowserRow } from './BrowserRow'; +import { DrivenSection } from './DrivenSection'; export interface BrowserMenuProps { onClose(): void; @@ -18,17 +19,25 @@ export interface BrowserMenuProps { onShowSettings?: () => void; } -/** The browser menu's content: a search, and each browser offered; choosing one opens the page there. */ +/** + * The browser menu's content: a search, each browser offered (choosing one opens the page there), and the browsers + * driven with the workspace's changes, with their tabs. + */ export function BrowserMenu({ onClose, onShowSettings }: BrowserMenuProps) { const [query, setQuery] = useState(''); const browsers = useBrowserStore(useShallow(selectShownBrowsers)); const loaded = useBrowserStore((s) => s.loaded); + const driven = useBrowserStore((s) => s.driven); const onWeb = usePageStore((s) => webAddress(s.page.url) !== ''); const matches = browsers.filter((b) => matchesBrowser(b, query.trim())); const open = (browser: BrowserInfo) => { onClose(); void openInBrowser(browser); }; + const openChanged = (browser: BrowserInfo) => { + onClose(); + void openWithChanges(browser); + }; return (
@@ -40,7 +49,7 @@ export function BrowserMenu({ onClose, onShowSettings }: BrowserMenuProps) { onKeyDown={(event) => { if (event.key === KEY.enter && onWeb && matches[0]) open(matches[0]); }} - placeholder="Search browsers…" + placeholder={driven.length ? 'Search browsers and tabs…' : 'Search browsers…'} aria-label="Search browsers" leading={} className="flex-1" @@ -50,13 +59,14 @@ export function BrowserMenu({ onClose, onShowSettings }: BrowserMenuProps) { {onWeb ? null :

Open a website first, then open it in another browser from here.

}
{matches.map((browser) => ( - + d.id === browser.id)} onOpen={open} onOpenWithChanges={openChanged} /> ))} {!loaded ? : null} {loaded && !matches.length ? (

{browsers.length ? 'No browser matches.' : 'No other browsers were found. Add one in Settings › Browsers.'}

) : null}
+
); } diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx index 25abd89..d108f3c 100644 --- a/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx @@ -1,27 +1,47 @@ import type { BrowserInfo } from '@common/types'; +import { icons } from '@/shared/config'; +import { IconButton } from '@/shared/ui/icon-button'; import { BrowserIcon } from '@/entities/browser'; export interface BrowserRowProps { browser: BrowserInfo; /** The page can't be opened elsewhere (none is shown, or it isn't on the web). */ disabled: boolean; + /** The app drives it with the workspace's changes (it is marked). */ + driven: boolean; onOpen(browser: BrowserInfo): void; + onOpenWithChanges(browser: BrowserInfo): void; } -/** A browser in the menu: its icon, name and version; choosing it opens the page there. */ -export function BrowserRow({ browser, disabled, onOpen }: BrowserRowProps) { +/** + * A browser in the menu: its icon, name and version; choosing it opens the page there. A Chromium browser also offers + * opening it with the workspace's changes, in a profile of the app's own. + */ +export function BrowserRow({ browser, disabled, driven, onOpen, onOpenWithChanges }: BrowserRowProps) { return ( - +
+ + {browser.engine === 'chromium' ? ( + onOpenWithChanges(browser)} + data-testid="browser-open-with-changes" + className="mr-1" + /> + ) : null} +
); } diff --git a/src/renderer/src/features/browser/open-in-browser/ui/DrivenSection.tsx b/src/renderer/src/features/browser/open-in-browser/ui/DrivenSection.tsx new file mode 100644 index 0000000..5ec731c --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/ui/DrivenSection.tsx @@ -0,0 +1,41 @@ +import type { BrowserInfo } from '@common/types'; +import { icons } from '@/shared/config'; +import { IconButton } from '@/shared/ui/icon-button'; +import { BrowserIcon, useBrowserStore } from '@/entities/browser'; +import { matchesTab, stopDriving } from '../model'; +import { DrivenTabRow } from './DrivenTabRow'; + +export interface DrivenSectionProps { + /** The menu's search, narrowing the tabs. */ + query: string; + onClose(): void; +} + +/** The browsers the app drives with the workspace's changes, each with its tabs (the ones the search finds). */ +export function DrivenSection({ query, onClose }: DrivenSectionProps) { + const driven = useBrowserStore((s) => s.driven); + const browsers = useBrowserStore((s) => s.browsers); + if (!driven.length) return null; + const iconOf = (id: string, name: string): Pick => browsers.find((b) => b.id === id) ?? { icon: null, name }; + + return ( +
+ {driven.map((browser) => { + const tabs = browser.tabs.filter((tab) => matchesTab(tab, query)); + return ( +
+
+ + {browser.name}, with your changes + void stopDriving(browser)} /> +
+ {tabs.map((tab) => ( + + ))} + {tabs.length ? null :

{browser.tabs.length ? 'No tab matches.' : 'No tabs open.'}

} +
+ ); + })} +
+ ); +} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/DrivenTabRow.tsx b/src/renderer/src/features/browser/open-in-browser/ui/DrivenTabRow.tsx new file mode 100644 index 0000000..4adf9d8 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/ui/DrivenTabRow.tsx @@ -0,0 +1,42 @@ +import type { DrivenBrowser, DrivenTab } from '@common/types'; +import { icons } from '@/shared/config'; +import { webAddress } from '@/shared/lib'; +import { IconButton } from '@/shared/ui/icon-button'; +import { activateTab, captureTab, openTabHere } from '../model'; + +export interface DrivenTabRowProps { + browser: DrivenBrowser; + tab: DrivenTab; + onClose(): void; +} + +/** A tab of a driven browser: choosing it brings it to the front; it can be loaded here, or captured. */ +export function DrivenTabRow({ browser, tab, onClose }: DrivenTabRowProps) { + const onWeb = webAddress(tab.url) !== ''; + return ( +
+ + { + onClose(); + void openTabHere(tab); + }} + /> + void captureTab(browser, tab)} data-testid="driven-tab-capture" className="mr-1" /> +
+ ); +} diff --git a/src/renderer/src/shared/lib/index.ts b/src/renderer/src/shared/lib/index.ts index b76e365..41183a7 100644 --- a/src/renderer/src/shared/lib/index.ts +++ b/src/renderer/src/shared/lib/index.ts @@ -4,7 +4,7 @@ export { cn } from './cn'; export { firstErrorMessage } from './form'; export { DURATION, EASE_OUT, ICON_PRESS_SCALE, PRESS_SCALE, SLIDE_IN_X, SPRING_LAYOUT, SPRING_PANEL, SPRING_PRESS, SPRING_SWAP } from './motion'; export { fileName, hostOf, pathFileName, pathOf, pathSegments, webAddress } from './url'; -export { predicateFor } from './match'; +export { matchesWords, predicateFor } from './match'; export { isMac, keyLabel } from './platform'; export { formatCode, looksMinified, responseText } from './format'; export { formatTime } from './formatTime'; diff --git a/src/renderer/src/shared/lib/match/index.ts b/src/renderer/src/shared/lib/match/index.ts index cb0d348..1b75a01 100644 --- a/src/renderer/src/shared/lib/match/index.ts +++ b/src/renderer/src/shared/lib/match/index.ts @@ -1 +1,2 @@ +export { matchesWords } from './matchesWords'; export { predicateFor } from './predicateFor'; diff --git a/src/renderer/src/shared/lib/match/matchesWords.ts b/src/renderer/src/shared/lib/match/matchesWords.ts new file mode 100644 index 0000000..587024f --- /dev/null +++ b/src/renderer/src/shared/lib/match/matchesWords.ts @@ -0,0 +1,8 @@ +/** Whether a search answers `text`: every word of `query` is in it, case aside. */ +export function matchesWords(text: string, query: string): boolean { + const haystack = text.toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .every((word) => haystack.includes(word)); +} diff --git a/src/renderer/src/widgets/command-palette/ui/browserItems.ts b/src/renderer/src/widgets/command-palette/ui/browserItems.ts index d6c909f..bb1a642 100644 --- a/src/renderer/src/widgets/command-palette/ui/browserItems.ts +++ b/src/renderer/src/widgets/command-palette/ui/browserItems.ts @@ -1,19 +1,33 @@ import type { BrowserInfo } from '@common/types'; import { icons } from '@/shared/config'; import type { CommandItem } from '@/shared/ui/command-palette'; -import { openInBrowser } from '@/features/browser/open-in-browser'; +import { openInBrowser, openWithChanges } from '@/features/browser/open-in-browser'; -/** A palette item's id prefix for opening the page in a browser. */ +/** Palette items' id prefixes: opening the page in a browser, and in a Chromium one with the workspace's changes. */ const OPEN_IN_PREFIX = 'open-in:'; +const OPEN_CHANGED_PREFIX = 'open-changed:'; -/** Opening the page in each browser offered, while it is on the web. */ +/** Opening the page in each browser offered, and in each Chromium one with the workspace's changes, while it is on the web. */ export function browserItems(browsers: BrowserInfo[], onWeb: boolean): CommandItem[] { if (!onWeb) return []; - return browsers.map((browser) => ({ - id: `${OPEN_IN_PREFIX}${browser.id}`, - label: `Open in ${browser.name}`, - icon: icons.BrowserIcon, - keywords: ['browser', 'other browser', browser.engine], - onSelect: () => void openInBrowser(browser), - })); + return browsers.flatMap((browser) => [ + { + id: `${OPEN_IN_PREFIX}${browser.id}`, + label: `Open in ${browser.name}`, + icon: icons.BrowserIcon, + keywords: ['browser', 'other browser', browser.engine], + onSelect: () => void openInBrowser(browser), + }, + ...(browser.engine === 'chromium' + ? [ + { + id: `${OPEN_CHANGED_PREFIX}${browser.id}`, + label: `Open in ${browser.name} with your changes`, + icon: icons.OverridesIcon, + keywords: ['browser', 'overrides', 'rules', 'changes', browser.engine], + onSelect: () => void openWithChanges(browser), + }, + ] + : []), + ]); } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 46e99e2..6fc2956 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -24,7 +24,7 @@ export const ACTIONS_WINDOW_EVENTS = ['actions-changed', 'actions-window', 'fram * The events the website's own window is sent besides its page's state and its shortcut: what its toolbar's menus * show. Its UI has a handler for each, so one added here fails typecheck until it is handled there. */ -export const PAGE_WINDOW_EVENTS = ['browsers-changed', 'shots-changed', 'overlay-changed'] as const satisfies readonly AppEvent['type'][]; +export const PAGE_WINDOW_EVENTS = ['browsers-changed', 'driven-browsers-changed', 'shots-changed', 'overlay-changed'] as const satisfies readonly AppEvent['type'][]; /** * The events that can carry megabytes (a batch of commits of hundreds of components, of requests with their diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts index b54b4ca..e507260 100644 --- a/src/shared/ipcChannels.ts +++ b/src/shared/ipcChannels.ts @@ -92,10 +92,15 @@ export const IPC_CHANNEL = { addBrowser: 'browsers:add', removeBrowser: 'browsers:remove', setBrowserHidden: 'browsers:hidden', + openWithChanges: 'browsers:open-with-changes', + listDriven: 'browsers:driven', + activateTab: 'browsers:activate-tab', + stopDriving: 'browsers:stop-driving', listShots: 'shots:list', captureShot: 'shots:capture', captureElementShot: 'shots:capture-element', + captureTabShot: 'shots:capture-tab', readShot: 'shots:read', renameShot: 'shots:rename', deleteShot: 'shots:delete', diff --git a/src/shared/types/browsers.ts b/src/shared/types/browsers.ts index 5829d5a..ce1b9ec 100644 --- a/src/shared/types/browsers.ts +++ b/src/shared/types/browsers.ts @@ -18,3 +18,20 @@ export interface BrowserInfo { /** Turned off in Settings › Browsers: not offered beside the address bar. */ hidden: boolean; } + +/** A tab of a browser the app drives. */ +export interface DrivenTab { + /** Its target id. */ + id: string; + title: string; + url: string; +} + +/** A browser the app launched with a profile of its own, and serves the workspace's overrides and rules in. */ +export interface DrivenBrowser { + /** The installed browser's id. */ + id: string; + name: string; + version: string | null; + tabs: DrivenTab[]; +} diff --git a/src/shared/types/browsersApi.ts b/src/shared/types/browsersApi.ts index 21f64e8..f60ea00 100644 --- a/src/shared/types/browsersApi.ts +++ b/src/shared/types/browsersApi.ts @@ -1,4 +1,4 @@ -import type { BrowserInfo } from './browsers'; +import type { BrowserInfo, DrivenBrowser } from './browsers'; /** The other browsers' part of the API exposed to the renderer (`ConsoleEditorApi`). */ export interface BrowsersApi { @@ -12,4 +12,15 @@ export interface BrowsersApi { removeBrowser(id: string): Promise; /** Offers a browser beside the address bar again, or stops offering it. */ setBrowserHidden(id: string, hidden: boolean): Promise; + /** + * Opens an http(s) address in a Chromium browser with a profile of the app's own, serving the workspace's overrides + * and rules in its tabs (launching it, or reaching it when the app launched it before and it is still open). + */ + openWithChanges(id: string, url: string): Promise; + /** The browsers the app drives, with their tabs. */ + listDriven(): Promise; + /** Brings a tab of a driven browser to the front. */ + activateTab(browserId: string, tabId: string): Promise; + /** Stops serving the workspace's changes in a driven browser (it stays open, as it is). */ + stopDriving(browserId: string): Promise; } diff --git a/src/shared/types/events.ts b/src/shared/types/events.ts index bbb56fd..719b10d 100644 --- a/src/shared/types/events.ts +++ b/src/shared/types/events.ts @@ -1,5 +1,5 @@ import type { ActionsWindowState, ConsoleAction } from './actions'; -import type { BrowserInfo } from './browsers'; +import type { BrowserInfo, DrivenBrowser } from './browsers'; import type { HeldRequest } from './breakpoints'; import type { ConsoleEntry, ConsoleFrame } from './console'; import type { FrameStack, InspectedComponent, InspectHover, RenderCommit, StoreAction } from './inspector'; @@ -90,6 +90,8 @@ export type AppEvent = | { type: 'settings-changed'; settings: Settings } /** The browsers on this computer were looked for again, a version was read, or one was added, removed, hidden or shown. */ | { type: 'browsers-changed'; browsers: BrowserInfo[] } + /** A browser the app drives opened, closed, or one of its tabs opened, closed or changed its address or title. */ + | { type: 'driven-browsers-changed'; driven: DrivenBrowser[] } /** The active workspace's shots, newest first: one was taken, imported, renamed or deleted, or another workspace became active. */ | { type: 'shots-changed'; shots: Shot[] } /** A design was laid over the page, its settings changed, or it was taken off (null). */ diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 868e8da..f313f32 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -8,7 +8,7 @@ export type { ActionInput, ActionPatch, ActionsWindowState, ConsoleAction } from export type { Breakpoint, BreakpointStage, FailReason, HeldAction, HeldActionType, HeldRequest, HeldResponse } from './breakpoints'; export { BREAKPOINT_STAGES, FAIL_REASONS } from './breakpoints'; export type { ConsoleEditorApi } from './api'; -export type { BrowserEngine, BrowserInfo } from './browsers'; +export type { BrowserEngine, BrowserInfo, DrivenBrowser, DrivenTab } from './browsers'; export { BROWSER_ENGINES } from './browsers'; export type { BrowsersApi } from './browsersApi'; export type { ConsoleEntry, ConsoleFrame, ConsoleLevel, ConsoleLocation, ConsoleProperty, ConsoleSource, ConsoleValue, ConsoleValueKind } from './console'; diff --git a/src/shared/types/shotsApi.ts b/src/shared/types/shotsApi.ts index 7a0cab4..e6fc34f 100644 --- a/src/shared/types/shotsApi.ts +++ b/src/shared/types/shotsApi.ts @@ -6,6 +6,8 @@ export interface ShotsApi { listShots(): Promise; /** Captures the page shown, what its viewport shows or all of it, and keeps it with the workspace. */ captureShot(area: Exclude): Promise; + /** Captures a tab of a browser the app drives: what it shows, or all of it. */ + captureTabShot(browserId: string, tabId: string, area: Exclude): Promise; /** Captures the element of a pick (the inspector's), in any frame. */ captureElementShot(pickId: string): Promise; /** A shot's file, for reading its pixels. */ diff --git a/test/e2e/driven.e2e.test.ts b/test/e2e/driven.e2e.test.ts new file mode 100644 index 0000000..428d1be --- /dev/null +++ b/test/e2e/driven.e2e.test.ts @@ -0,0 +1,131 @@ +/** + * A Chromium browser driven with the workspace's changes, in the built app on Linux: a real Chromium installed as a + * launcher is offered "with your changes" in the browser menu; the page opens there served the workspace's override, + * its tab is listed under the browser and captured into the shots, and letting go of it takes it off the menu. + */ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium, _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readActivePort } from '../../src/main/browsers/driven/readActivePort'; +import { CdpConnection } from '../../src/main/engine/websocketTransport'; +import type { DrivenBrowser, Shot } from '../../src/shared/types'; + +const root = resolve(__dirname, '../..'); +const built = existsSync(join(root, 'out/main/index.js')); +const chromiumPath = (() => { + try { + return existsSync(chromium.executablePath()) ? chromium.executablePath() : null; + } catch { + return null; + } +})(); +// Chromium's sandbox can't start as root (containers). +const sandboxArgs = process.getuid?.() === 0 ? ['--no-sandbox'] : []; + +const EDITOR_URL = /\/renderer\/index\.html$/; +const BROWSER_ID = 'desktop:test-chromium.desktop'; + +/** Polls until `fn` returns a truthy value (usable outside tests, unlike expect.poll). */ +async function waitFor(fn: () => T | undefined | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!built || !chromiumPath || process.platform !== 'linux')('A browser with your changes', () => { + let server: Server; + let origin: string; + let dir: string; + let app: ElectronApplication; + let win: Page; + + type Api = { consoleEditor: { listDriven(): Promise; listShots(): Promise; createOverride(input: object): Promise } }; + const driven = () => win.evaluate(() => (window as unknown as Api).consoleEditor.listDriven()); + const shots = () => win.evaluate(() => (window as unknown as Api).consoleEditor.listShots()); + const menu = () => win.getByTestId('browser-menu'); + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const files: Record = { + '/page.html': ['text/html', 'upstream'], + '/app.js': ['text/javascript', "document.title = 'upstream';"], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + dir = await mkdtemp(join(tmpdir(), 'console-editor-e2e-driven-')); + mkdirSync(join(dir, 'data/applications'), { recursive: true }); + mkdirSync(join(dir, 'system'), { recursive: true }); + // Headless, so the test needs no screen of its own for it. + writeFileSync(join(dir, 'data/applications/test-chromium.desktop'), `[Desktop Entry]\nType=Application\nName=Test Chromium\nExec=${chromiumPath} --headless=new ${sandboxArgs.join(' ')} %u\nCategories=Network;WebBrowser;\n`); + + app = await electron.launch({ + args: [...sandboxArgs, root], + cwd: root, + env: { ...process.env, CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, + }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + const bar = win.getByTestId('address-bar'); + await bar.fill(`${origin}/page.html`); + await bar.press('Enter'); + await waitFor(() => app.evaluate(({ webContents }, o) => webContents.getAllWebContents().some((wc) => wc.getURL() === `${o}/page.html` && !wc.isLoading()), origin)); + await win.evaluate((o) => (window as unknown as Api).consoleEditor.createOverride({ kind: 'Script', sourceUrl: `${o}/app.js`, content: "document.title = 'overridden';", originalHash: null }), origin); + }); + + afterAll(async () => { + // The browser outlives the app by design: quit it through its own port (first: Playwright waits for everything + // the app started to be gone before it takes the app for closed). + const address = dir ? await readActivePort(join(dir, 'user-data/browsers/desktop_test-chromium.desktop')) : null; + const connection = address ? await CdpConnection.connect(address).catch(() => null) : null; + await connection?.send('Browser.close').catch(() => undefined); + connection?.close(); + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(dir, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('opens the page in a Chromium browser with your changes, and lists its tab under it', async () => { + await win.getByTestId('browser-menu-button').click(); + const row = menu().locator(`[data-testid="browser-row"][data-browser-id="${BROWSER_ID}"]`); + await row.getByTestId('browser-open-with-changes').click(); + // Served the override: the title its script sets. + await expect.poll(async () => (await driven())[0]?.tabs.map((t) => t.title), { timeout: 30_000 }).toEqual(['overridden']); + expect((await driven())[0]).toMatchObject({ id: BROWSER_ID, name: 'Test Chromium', version: expect.stringMatching(/^\d+\./) }); + + await win.getByTestId('browser-menu-button').click(); + await expect.poll(() => row.getByTestId('browser-driven').count()).toBe(1); + const tab = menu().getByTestId('driven-tab'); + await expect.poll(() => tab.innerText()).toContain('overridden'); + expect(await tab.innerText()).toContain(`${origin}/page.html`); + await menu().getByRole('textbox', { name: 'Search browsers' }).fill('nothing like it'); + await menu().getByText('No tab matches.').waitFor(); + await menu().getByRole('textbox', { name: 'Search browsers' }).fill(''); + }); + + it("captures its tab into the shots, with the browser's name", async () => { + await menu().getByTestId('driven-tab-capture').click(); + await expect.poll(async () => (await shots()).length, { timeout: 15_000 }).toBe(1); + const [shot] = await shots(); + expect(shot).toMatchObject({ kind: 'capture', area: 'viewport', pageUrl: `${origin}/page.html`, browser: { id: BROWSER_ID, name: 'Test Chromium' } }); + }); + + it('lets go of it, taking it off the menu', async () => { + await menu().getByRole('button', { name: 'Stop serving your changes in Test Chromium' }).click(); + await expect.poll(() => driven()).toEqual([]); + await expect.poll(() => menu().getByTestId('driven-browsers').count()).toBe(0); + }); +}); diff --git a/test/integration/driven.chromium.test.ts b/test/integration/driven.chromium.test.ts new file mode 100644 index 0000000..ba06188 --- /dev/null +++ b/test/integration/driven.chromium.test.ts @@ -0,0 +1,144 @@ +/** + * A Chromium browser driven with the workspace's changes: launched with a profile of the app's own and a debugging + * port, every tab served the overrides (a new tab from its first request), changes served and the tabs reloaded, a tab + * captured, the browser let go of (it stays open) and reached again rather than launched twice, and forgotten once + * it is quit. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DrivenBrowsers } from '../../src/main/browsers'; +import { readActivePort } from '../../src/main/browsers/driven/readActivePort'; +import type { InterceptionSources } from '../../src/main/browsers/driven/types'; +import type { FoundBrowser } from '../../src/main/browsers'; +import { CdpConnection } from '../../src/main/engine/websocketTransport'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type AppEvent, type DrivenTab, type Override, type Settings } from '../../src/shared/types'; +import { chromiumAvailable } from '../helpers/chromium'; +import { decodePng } from '../helpers/decodePng'; + +async function waitFor(fn: () => T | undefined | false | Promise, timeout = 20_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes', () => { + let server: Server; + let origin: string; + let userData: string; + let driven: DrivenBrowsers; + const events: AppEvent[] = []; + const overrides: Override[] = []; + const settings: Settings = { ...DEFAULT_SETTINGS, autoReloadOnSave: true }; + const sources = { store: { list: () => overrides, base: async () => '' }, rules: { list: () => [] }, settings: { get: () => settings } } as unknown as InterceptionSources; + const browser: FoundBrowser = { + id: 'desktop:test-chromium.desktop', + name: 'Test Chromium', + engine: 'chromium', + // Headless here; the sandbox can't start as root (containers). + command: [chromium.executablePath(), '--headless=new', ...(process.getuid?.() === 0 ? ['--no-sandbox'] : [])], + urlAt: process.getuid?.() === 0 ? 3 : 2, + iconFile: null, + app: null, + program: chromium.executablePath(), + added: false, + }; + const profile = () => join(userData, 'browsers', 'desktop_test-chromium.desktop'); + const tabs = (): DrivenTab[] => driven.list()[0]?.tabs ?? []; + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const files: Record = { + '/page.html': ['text/html', 'upstream'], + '/app.js': ['text/javascript', "document.title = 'upstream';"], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + userData = await mkdtemp(join(tmpdir(), 'console-editor-driven-')); + const now = Date.now(); + overrides.push({ id: 'o1', kind: 'Script', sourceUrl: `${origin}/app.js`, match: defaultMatcherFor(`${origin}/app.js`), content: "document.title = 'overridden';", enabled: true, originalHash: null, createdAt: now, updatedAt: now }); + driven = new DrivenBrowsers({ registry: { get: async () => browser }, sources, userData, send: (event) => events.push(event) }); + }); + + afterAll(async () => { + driven?.dispose(); + // The browser outlives the app by design: quit it through its own port. + const address = userData ? await readActivePort(profile()) : null; + const connection = address ? await CdpConnection.connect(address).catch(() => null) : null; + await connection?.send('Browser.close').catch(() => undefined); + connection?.close(); + await new Promise((r) => server?.close(r)); + await rm(userData, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('launches it with a profile of its own, and serves the overrides in the tab it opens', async () => { + await driven.open(browser.id, `${origin}/page.html`); + await waitFor(() => tabs().some((t) => t.url === `${origin}/page.html` && t.title === 'overridden')); + expect(driven.list()).toEqual([{ id: browser.id, name: 'Test Chromium', version: expect.stringMatching(/^\d+\./), tabs: expect.any(Array) }]); + expect(existsSync(join(profile(), 'DevToolsActivePort'))).toBe(true); + expect(events.at(-1)).toMatchObject({ type: 'driven-browsers-changed', driven: [{ id: browser.id }] }); + }); + + it('opens another address in a new tab, served the overrides from its first request', async () => { + await driven.open(browser.id, `${origin}/page.html?second`); + await waitFor(() => tabs().some((t) => t.url.endsWith('?second') && t.title === 'overridden')); + expect(tabs()).toHaveLength(2); + }); + + it('serves overrides as they change, reloading the tabs when the settings say so', async () => { + overrides[0] = { ...overrides[0], content: "document.title = 'changed';", updatedAt: Date.now() }; + driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); + await waitFor(() => tabs().length === 2 && tabs().every((t) => t.title === 'changed')); + }); + + it("captures a tab, with its address and the browser's name and version", async () => { + const [tab] = tabs(); + const { image, url, browser: taken } = await driven.capture(browser.id, tab.id, 'viewport'); + expect(url).toBe(tab.url); + expect(taken).toEqual({ id: browser.id, name: 'Test Chromium', version: driven.list()[0].version }); + expect(image.width).toBeGreaterThan(0); + expect(decodePng(image.bytes).at(5, 5)).toEqual([255, 0, 0, 255]); + await expect(driven.capture(browser.id, tab.id, 'element')).rejects.toThrow('Invalid capture area'); + }); + + it('lets go of it (it stays open), and reaches it again rather than launching another', async () => { + const port = readFileSync(join(profile(), 'DevToolsActivePort'), 'utf8'); + driven.stop(browser.id); + expect(driven.list()).toEqual([]); + expect(events.at(-1)).toEqual({ type: 'driven-browsers-changed', driven: [] }); + + await driven.open(browser.id, `${origin}/page.html?third`); + expect(readFileSync(join(profile(), 'DevToolsActivePort'), 'utf8')).toBe(port); + await waitFor(() => tabs().some((t) => t.url.endsWith('?third') && t.title === 'changed')); + expect(tabs()).toHaveLength(3); + }); + + it('refuses a browser other than Chromium, and an address not on the web', async () => { + const other = new DrivenBrowsers({ registry: { get: async () => ({ ...browser, name: 'Firefox', engine: 'gecko' }) }, sources, userData, send: () => undefined }); + await expect(other.open(browser.id, `${origin}/page.html`)).rejects.toThrow("Firefox can't be served your changes"); + await expect(driven.open(browser.id, 'file:///etc/passwd')).rejects.toThrow('Only http(s) pages'); + }); + + it('forgets it once it is quit', async () => { + const connection = await CdpConnection.connect((await readActivePort(profile()))!); + await connection.send('Browser.close').catch(() => undefined); + connection.close(); + await waitFor(() => driven.list().length === 0); + expect(events.at(-1)).toEqual({ type: 'driven-browsers-changed', driven: [] }); + }); +}); diff --git a/test/renderer/bridge-start.test.ts b/test/renderer/bridge-start.test.ts index f173493..254df48 100644 --- a/test/renderer/bridge-start.test.ts +++ b/test/renderer/bridge-start.test.ts @@ -28,6 +28,7 @@ const api = vi.hoisted(() => ({ listShots: vi.fn(), getOverlay: vi.fn(), listBrowsers: vi.fn(), + listDriven: vi.fn(), getPageState: vi.fn(), sessionFlushed: vi.fn(), })); @@ -120,6 +121,7 @@ describe('start bridge', () => { api.listShots.mockResolvedValue([]); api.getOverlay.mockResolvedValue(null); api.listBrowsers.mockResolvedValue([]); + api.listDriven.mockResolvedValue([]); api.getPageState.mockResolvedValue(PAGE); }); afterEach(() => { diff --git a/test/unit/browsers.test.ts b/test/unit/browsers.test.ts index 4e5cee4..a6df4f5 100644 --- a/test/unit/browsers.test.ts +++ b/test/unit/browsers.test.ts @@ -1,6 +1,7 @@ /** * Other browsers: finding them the way each system lists them (Linux launchers and icon themes, macOS apps, the - * Windows registry), telling their engines apart, what the user added or hid, and opening a page in one. + * Windows registry), telling their engines apart, what the user added or hid, and opening a page in one; and how a + * Chromium browser is started to be driven (its command, profile folder and debugging port's address) and its tabs kept. */ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -29,6 +30,11 @@ const { programOfCommand } = await import('../../src/main/browsers/findBrowsers/ const { engineOf } = await import('../../src/main/browsers/engineOf'); const { launchEnv } = await import('../../src/main/browsers/launchEnv'); const { BrowserRegistry } = await import('../../src/main/browsers'); +const { driveCommand } = await import('../../src/main/browsers/driven/driveCommand'); +const { profileDir } = await import('../../src/main/browsers/driven/profileDir'); +const { readActivePort } = await import('../../src/main/browsers/driven/readActivePort'); +const { DrivenTabs } = await import('../../src/main/browsers/driven/DrivenTabs'); +type DrivenTabState = import('../../src/main/browsers/driven/types').DrivenTabState; const { BrowserStore } = await import('../../src/main/store/BrowserStore'); type FoundBrowser = import('../../src/main/browsers').FoundBrowser; @@ -275,3 +281,63 @@ describe('The registry', () => { await expect(r.add(tmp)).rejects.toThrow("isn't a program"); }); }); + +describe('Driving a Chromium browser', () => { + const chrome = (command: string[], urlAt = command.length, extra: Partial = {}): FoundBrowser => ({ + id: 'desktop:google-chrome.desktop', + name: 'Google Chrome', + engine: 'chromium', + command, + urlAt, + iconFile: null, + app: null, + program: command[0], + added: false, + ...extra, + }); + const flags = ['--user-data-dir=/p', '--remote-debugging-port=0']; + + it('puts its flags and address where the browser takes them', () => { + expect(driveCommand(chrome(['/usr/bin/google-chrome', '--incognito']), flags, 'about:blank')).toEqual(['/usr/bin/google-chrome', '--incognito', ...flags, 'about:blank']); + // A launcher's address in the middle stays in the middle. + expect(driveCommand(chrome(['/usr/bin/chrome', '--x'], 1), flags, 'about:blank')).toEqual(['/usr/bin/chrome', ...flags, 'about:blank', '--x']); + // Flatpak: before its forwarded-file markers, which go. + expect(driveCommand(chrome(['/usr/bin/flatpak', 'run', 'org.chromium.Chromium', '@@u', '@@'], 3), flags, 'about:blank')).toEqual(['/usr/bin/flatpak', 'run', 'org.chromium.Chromium', ...flags, 'about:blank']); + // macOS: a new instance of the app, the flags after --args. + expect(driveCommand(chrome(['open', '-a', '/Applications/Google Chrome.app']), flags, 'about:blank')).toEqual(['open', '-n', '-a', '/Applications/Google Chrome.app', '--args', ...flags, 'about:blank']); + }); + + it("keeps its profile in the app's data, or where a Snap's or Flatpak's sandbox lets it write", () => { + expect(profileDir(chrome(['/usr/bin/google-chrome']), '/data')).toBe('/data/browsers/desktop_google-chrome.desktop'); + expect(profileDir(chrome(['/snap/bin/chromium']), '/data')).toMatch(/\/snap\/chromium\/common\/console-editor-profile$/); + expect(profileDir(chrome(['/usr/bin/flatpak', 'run', '--branch=stable', 'org.chromium.Chromium'], 4), '/data')).toMatch(/\/\.var\/app\/org\.chromium\.Chromium\/data\/console-editor-profile$/); + }); + + it('reads the address of its debugging port from its profile, once written', async () => { + const dir = join(tmp, 'profile'); + mkdirSync(dir, { recursive: true }); + expect(await readActivePort(dir)).toBeNull(); + writeFileSync(join(dir, 'DevToolsActivePort'), '9333\n/devtools/browser/abc\n'); + expect(await readActivePort(dir)).toBe('ws://127.0.0.1:9333/devtools/browser/abc'); + writeFileSync(join(dir, 'DevToolsActivePort'), '9333'); + expect(await readActivePort(dir)).toBeNull(); + }); + + it('keeps its tabs: by target and session, their changes, a blank one to reuse, and the one a new target becomes', async () => { + const tabs = new DrivenTabs(); + const tab = (id: string, url: string) => ({ info: { id, title: '', url }, sessionId: `s-${id}` }) as DrivenTabState; + tabs.add(tab('a', 'about:blank')); + expect(tabs.blank()?.info.id).toBe('a'); + expect(tabs.update({ targetId: 'a', type: 'page', url: 'https://shop.test/', title: 'Shop' })).toBe(true); + expect(tabs.update({ targetId: 'a', type: 'page', url: 'https://shop.test/', title: 'Shop' })).toBe(false); + expect(tabs.update({ targetId: 'gone', type: 'page', url: 'x', title: 'y' })).toBe(false); + expect(tabs.blank()).toBeUndefined(); + const arriving = tabs.arrival('b'); + tabs.add(tab('b', 'about:blank')); + expect((await arriving).info.id).toBe('b'); + expect(tabs.list()).toEqual([{ id: 'a', title: 'Shop', url: 'https://shop.test/' }, { id: 'b', title: '', url: 'about:blank' }]); + expect(tabs.remove('s-a')?.info.id).toBe('a'); + expect(() => tabs.get('a')).toThrow('That tab is closed'); + expect(tabs.clear().map((t) => t.info.id)).toEqual(['b']); + }); +}); From 057c5825860c16cf93a624b19b72f2e8cd76d236 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 21:57:12 +0000 Subject: [PATCH 07/19] Capture the page in every browser at once, and compare the captures "In every browser" (in the shots menu's Capture, and in the palette) captures the whole page in the app and then at the same address in every browser driven with the workspace's changes. Each one is laid out at the app's viewport and density, and captured once it has loaded and been quiet for a moment. The captures are kept as one group. A browser that fails is named in the notice rather than failing the rest. The group's page shows the captures side by side against a baseline: the app's capture, another capture of the group, or a design. Each cell shows the share of pixels that differ, and can show the difference itself. Choosing a cell opens the two in the compare page. The status bar now counts the tabs of other browsers that are served the workspace's changes. --- src/main/browsers/driven/DrivenBrowsers.ts | 18 +++++-- src/main/browsers/driven/DrivenChromium.ts | 46 +++++++++--------- src/main/browsers/driven/DrivenTabs.ts | 5 ++ src/main/browsers/driven/captureTabAt.ts | 14 ++++++ src/main/browsers/driven/constants.ts | 3 ++ src/main/browsers/driven/letTargetGo.ts | 8 ++++ src/main/browsers/driven/readTabInfos.ts | 9 ++++ src/main/browsers/driven/releaseTab.ts | 7 +++ src/main/browsers/driven/reloadTab.ts | 8 ++++ src/main/browsers/driven/shotBrowser.ts | 8 ++++ src/main/ipc/registerShotIpc.ts | 3 +- src/main/shots/PageShots/PageShots.ts | 9 +++- src/main/shots/capture/atViewport.ts | 19 ++++++++ src/main/shots/capture/atWidth.ts | 14 ++---- src/main/shots/capture/index.ts | 3 +- src/main/shots/capture/types.ts | 12 ++++- src/main/shots/captureInEveryBrowser.ts | 27 +++++++++++ src/main/shots/constants.ts | 13 ++++- src/main/shots/index.ts | 1 + src/preload/shotsApi.ts | 1 + .../app/model/bridge/commands/activeSavers.ts | 1 + src/renderer/src/entities/browser/index.ts | 2 +- .../src/entities/browser/model/store/index.ts | 1 + .../model/store/selectDrivenTabCount.ts | 4 ++ .../editor-tab/model/store/constants.ts | 2 +- .../editor-tab/model/store/pageDirtyChecks.ts | 1 + .../entities/editor-tab/model/store/types.ts | 4 +- src/renderer/src/entities/shot/index.ts | 2 +- .../src/entities/shot/lib/browserLabel.ts | 7 +++ src/renderer/src/entities/shot/lib/index.ts | 1 + .../src/entities/shot/lib/shotDetail.ts | 4 +- .../src/features/shot/capture/index.ts | 2 +- .../shot/capture/model/captureEverywhere.ts | 26 ++++++++++ .../src/features/shot/capture/model/index.ts | 1 + .../src/features/shot/compare/index.ts | 2 +- .../features/shot/compare/model/constants.ts | 3 ++ .../src/features/shot/compare/model/index.ts | 1 + .../features/shot/compare/model/openGroup.ts | 11 +++++ .../shot/open-shot/model/receiveShots.ts | 8 ++-- .../widgets/command-palette/ui/shotItems.ts | 12 ++++- .../ui/EditorPanel/pageTabIcons.ts | 1 + .../editor-panel/ui/EditorPanel/pageViews.ts | 2 + .../editor-panel/ui/GroupPage/GroupCell.tsx | 47 +++++++++++++++++++ .../editor-panel/ui/GroupPage/GroupHeader.tsx | 46 ++++++++++++++++++ .../editor-panel/ui/GroupPage/GroupPage.tsx | 32 +++++++++++++ .../editor-panel/ui/GroupPage/constants.ts | 11 +++++ .../editor-panel/ui/GroupPage/index.ts | 1 + .../editor-panel/ui/GroupPage/shotLabel.ts | 9 ++++ .../editor-panel/ui/GroupPage/types.ts | 2 + .../editor-panel/ui/ShotPage/ShotActions.tsx | 7 ++- .../page-preview/ui/ShotsMenu/CaptureMenu.tsx | 8 +++- .../page-preview/ui/ShotsMenu/ShotsMenu.tsx | 5 +- .../ui/ShotsMenu/ShotsMenuButton.tsx | 9 +++- .../src/widgets/status-bar/ui/DrivenChip.tsx | 18 +++++++ .../src/widgets/status-bar/ui/StatusBar.tsx | 2 + src/shared/constants.ts | 3 ++ src/shared/ipcChannels.ts | 1 + src/shared/types/index.ts | 2 +- src/shared/types/shots.ts | 7 +++ src/shared/types/shotsApi.ts | 7 ++- test/e2e/driven.e2e.test.ts | 26 +++++++++- test/integration/driven.chromium.test.ts | 13 ++++- test/renderer/pageTabs.test.ts | 2 +- 63 files changed, 517 insertions(+), 67 deletions(-) create mode 100644 src/main/browsers/driven/captureTabAt.ts create mode 100644 src/main/browsers/driven/letTargetGo.ts create mode 100644 src/main/browsers/driven/readTabInfos.ts create mode 100644 src/main/browsers/driven/releaseTab.ts create mode 100644 src/main/browsers/driven/reloadTab.ts create mode 100644 src/main/browsers/driven/shotBrowser.ts create mode 100644 src/main/shots/capture/atViewport.ts create mode 100644 src/main/shots/captureInEveryBrowser.ts create mode 100644 src/renderer/src/entities/browser/model/store/selectDrivenTabCount.ts create mode 100644 src/renderer/src/entities/shot/lib/browserLabel.ts create mode 100644 src/renderer/src/features/shot/capture/model/captureEverywhere.ts create mode 100644 src/renderer/src/features/shot/compare/model/openGroup.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupCell.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupHeader.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupPage.tsx create mode 100644 src/renderer/src/widgets/editor-panel/ui/GroupPage/constants.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/GroupPage/index.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/GroupPage/shotLabel.ts create mode 100644 src/renderer/src/widgets/editor-panel/ui/GroupPage/types.ts create mode 100644 src/renderer/src/widgets/status-bar/ui/DrivenChip.tsx diff --git a/src/main/browsers/driven/DrivenBrowsers.ts b/src/main/browsers/driven/DrivenBrowsers.ts index 85d0445..25f2447 100644 --- a/src/main/browsers/driven/DrivenBrowsers.ts +++ b/src/main/browsers/driven/DrivenBrowsers.ts @@ -1,13 +1,14 @@ import type { AppEvent, CaptureArea, DrivenBrowser, ShotBrowser } from '../../../shared/types'; import { HTTP_URL } from '../../constants'; import { CDP } from '../../engine/constants'; -import type { CapturedImage } from '../../shots/capture'; +import type { BrowserCapture, CapturedImage, Viewport } from '../../shots/capture'; import { PAGE_AREAS } from '../../shots/constants'; import type { FoundBrowser } from '../types'; import { PRODUCT_VERSION, RELOAD_DEBOUNCE_MS } from './constants'; import { DrivenChromium } from './DrivenChromium'; import { profileDir } from './profileDir'; import { reachOrLaunch } from './reachOrLaunch'; +import { shotBrowser } from './shotBrowser'; import type { DrivenBrowsersDeps } from './types'; /** What each app event means for the driven browsers; the rest mean nothing to them. */ @@ -56,8 +57,19 @@ export class DrivenBrowsers { async capture(browserId: string, tabId: string, area: unknown): Promise<{ image: CapturedImage; url: string; browser: ShotBrowser }> { if (!PAGE_AREAS.has(area)) throw new Error('Invalid capture area'); const driven = this.get(browserId); - const { id, name, version } = driven.list(); - return { ...(await driven.capture(tabId, area as Exclude)), browser: { id, name, version } }; + return { ...(await driven.capture(tabId, area as Exclude)), browser: shotBrowser(driven) }; + } + + /** Captures the whole page at `url` in every driven browser at once, laid out in `viewport`; one that fails says why. */ + captureAt(url: string, viewport: Viewport): Promise { + return Promise.all( + this.all().map((driven) => + driven.captureAt(url, viewport).then( + (taken) => ({ ...taken, browser: shotBrowser(driven) }), + (err: unknown) => ({ browser: driven.browser.name, reason: err instanceof Error ? err.message : String(err) }), + ), + ), + ); } /** Stops serving the workspace's changes in a browser; it stays open. */ diff --git a/src/main/browsers/driven/DrivenChromium.ts b/src/main/browsers/driven/DrivenChromium.ts index ed4da6e..3f9be04 100644 --- a/src/main/browsers/driven/DrivenChromium.ts +++ b/src/main/browsers/driven/DrivenChromium.ts @@ -3,11 +3,16 @@ import { HTTP_URL } from '../../constants'; import { CDP } from '../../engine/constants'; import { withTimeout } from '../../engine/PageInterception'; import type { CdpConnection } from '../../engine/websocketTransport'; -import { captureOverCdp, type CapturedImage } from '../../shots/capture'; +import { captureOverCdp, type CapturedImage, type Viewport } from '../../shots/capture'; import type { FoundBrowser } from '../types'; import { attachTab } from './attachTab'; +import { captureTabAt } from './captureTabAt'; import { NEW_TAB_TIMEOUT_MS, PAGE_ATTACH, PAGE_TARGET, START_URL, STOP_ATTACH } from './constants'; import { DrivenTabs } from './DrivenTabs'; +import { letTargetGo } from './letTargetGo'; +import { readTabInfos } from './readTabInfos'; +import { releaseTab } from './releaseTab'; +import { reloadTab } from './reloadTab'; import type { AttachedPage, DrivenChromiumDeps, DrivenTabState, PageTargetInfo } from './types'; /** @@ -53,18 +58,27 @@ export class DrivenChromium { * read once the page has loaded, and whenever the tabs are listed. */ async readTabs(ids = this.tabs.list().map((t) => t.id)): Promise { - const infos = await Promise.all(ids.map((targetId) => this.connection.send<{ targetInfo: PageTargetInfo }>(CDP.Target.getTargetInfo, { targetId }).catch(() => null))); - if (infos.filter((info) => info && this.tabs.update(info.targetInfo)).length) this.deps.changed(); + const infos = await readTabInfos(this.connection, ids); + if (infos.filter((info) => this.tabs.update(info)).length) this.deps.changed(); } /** Opens an address in the blank tab the browser started on, or else in a new tab, and brings it to the front. */ - async open(url: string): Promise { + async open(url: string): Promise { const tab = this.tabs.blank() ?? (await this.newTab()); // Taken: another address opened before this one shows isn't loaded in it too. tab.info = { ...tab.info, url }; await tab.ready; await tab.transport.send(CDP.Page.navigate, { url }); await this.activate(tab.info.id); + return tab; + } + + /** Captures the whole page at `url`, in the tab showing it (or one opened there), laid out in `viewport`. */ + async captureAt(url: string, viewport: Viewport): Promise<{ image: CapturedImage; url: string }> { + const shown = this.tabs.showing(url); + const tab = shown ?? (await this.open(url)); + if (shown) await this.activate(tab.info.id); + return captureTabAt(tab, viewport); } /** Brings a tab to the front, in its window. */ @@ -93,24 +107,20 @@ export class DrivenChromium { /** Reloads the tabs showing a website, so what changed is served. */ async reload(): Promise { const pages = this.tabs.all().filter((t) => HTTP_URL.test(t.info.url)); - await Promise.all(pages.map((t) => this.reloadTab(t).catch(() => undefined))); + await Promise.all(pages.map((t) => reloadTab(t).catch(() => undefined))); } /** Stops serving the workspace's changes: the browser stays open, as it is. */ stop(): void { for (const dispose of this.disposers.splice(0)) dispose(); - for (const tab of this.tabs.clear()) this.release(tab); + for (const tab of this.tabs.clear()) releaseTab(tab); this.connection.send(CDP.Target.setAutoAttach, { ...STOP_ATTACH }).catch(() => undefined); this.connection.close(); } private attached(p: AttachedPage): void { - if (p.targetInfo.type !== PAGE_TARGET) { - // Only tabs are asked for; anything else is let go of as it came. - this.connection.send(CDP.Runtime.runIfWaitingForDebugger, {}, p.sessionId).catch(() => undefined); - this.connection.send(CDP.Target.detachFromTarget, { sessionId: p.sessionId }).catch(() => undefined); - return; - } + // Only tabs are asked for; anything else is let go of as it came. + if (p.targetInfo.type !== PAGE_TARGET) return letTargetGo(this.connection, p.sessionId); const tab = attachTab(this.connection, p, this.deps.sources); tab.transport.on(CDP.Page.loadEventFired, () => void this.readTabs([tab.info.id])); this.tabs.add(tab); @@ -120,7 +130,7 @@ export class DrivenChromium { private detached(sessionId: string): void { const tab = this.tabs.remove(sessionId); if (!tab) return; - this.release(tab); + releaseTab(tab); this.deps.changed(); } @@ -129,16 +139,6 @@ export class DrivenChromium { return withTimeout(this.tabs.arrival(targetId), NEW_TAB_TIMEOUT_MS, 'Opening a tab'); } - private async reloadTab(tab: DrivenTabState): Promise { - await tab.interception.prepareReload(tab.info.url); - await tab.transport.send(CDP.Page.reload, { ignoreCache: true }); - } - - private release(tab: DrivenTabState): void { - tab.interception.detach(); - void tab.transport.detach(); - } - /** The browser was quit: its tabs are gone with it. */ private closed(): void { for (const dispose of this.disposers.splice(0)) dispose(); diff --git a/src/main/browsers/driven/DrivenTabs.ts b/src/main/browsers/driven/DrivenTabs.ts index fc9d8d6..10b34b8 100644 --- a/src/main/browsers/driven/DrivenTabs.ts +++ b/src/main/browsers/driven/DrivenTabs.ts @@ -40,6 +40,11 @@ export class DrivenTabs { return tab ? Promise.resolve(tab) : new Promise((resolve) => this.arrivals.set(targetId, resolve)); } + /** The tab showing an address, if one does. */ + showing(url: string): DrivenTabState | undefined { + return this.all().find((t) => t.info.url === url); + } + /** A blank tab (the one the browser started on) to load an address in, rather than opening another. */ blank(): DrivenTabState | undefined { return this.all().find((t) => t.info.url === START_URL); diff --git a/src/main/browsers/driven/captureTabAt.ts b/src/main/browsers/driven/captureTabAt.ts new file mode 100644 index 0000000..e02d3ad --- /dev/null +++ b/src/main/browsers/driven/captureTabAt.ts @@ -0,0 +1,14 @@ +import { CDP } from '../../engine/constants'; +import { withTimeout } from '../../engine/PageInterception'; +import { atViewport, captureOverCdp, type CapturedImage, type Viewport } from '../../shots/capture'; +import { LOADED_EXPRESSION } from '../../shots/constants'; +import { LOAD_TIMEOUT_MS } from './constants'; +import type { DrivenTabState } from './types'; + +/** Captures the whole page a tab shows, laid out in `viewport`, once it has loaded and been quiet a moment. */ +export async function captureTabAt(tab: DrivenTabState, viewport: Viewport): Promise<{ image: CapturedImage; url: string }> { + // A page still loading after that is captured as it is. + await withTimeout(tab.transport.send(CDP.Runtime.evaluate, { expression: LOADED_EXPRESSION, awaitPromise: true }), LOAD_TIMEOUT_MS, 'Loading the page').catch(() => undefined); + const image = await atViewport(tab.transport, viewport, () => captureOverCdp(tab.transport, { area: 'page' })); + return { image, url: tab.info.url }; +} diff --git a/src/main/browsers/driven/constants.ts b/src/main/browsers/driven/constants.ts index cd3f84f..61bb8b3 100644 --- a/src/main/browsers/driven/constants.ts +++ b/src/main/browsers/driven/constants.ts @@ -39,6 +39,9 @@ export const PAGE_ATTACH = { autoAttach: true, waitForDebuggerOnStart: true, fla /** Letting go of a driven browser's tabs: new ones start as they would. */ export const STOP_ATTACH = { autoAttach: false, waitForDebuggerOnStart: false, flatten: true } as const; +/** How long a tab captured in every browser may take to load before it is captured as it is. */ +export const LOAD_TIMEOUT_MS = 30_000; + /** How long a tab the app opens has to be attached before opening it fails. */ export const NEW_TAB_TIMEOUT_MS = 10_000; diff --git a/src/main/browsers/driven/letTargetGo.ts b/src/main/browsers/driven/letTargetGo.ts new file mode 100644 index 0000000..be5da6c --- /dev/null +++ b/src/main/browsers/driven/letTargetGo.ts @@ -0,0 +1,8 @@ +import { CDP } from '../../engine/constants'; +import type { CdpConnection } from '../../engine/websocketTransport'; + +/** Lets a target that isn't a tab go on as it came: run, and no longer attached. */ +export function letTargetGo(connection: CdpConnection, sessionId: string): void { + connection.send(CDP.Runtime.runIfWaitingForDebugger, {}, sessionId).catch(() => undefined); + connection.send(CDP.Target.detachFromTarget, { sessionId }).catch(() => undefined); +} diff --git a/src/main/browsers/driven/readTabInfos.ts b/src/main/browsers/driven/readTabInfos.ts new file mode 100644 index 0000000..769b930 --- /dev/null +++ b/src/main/browsers/driven/readTabInfos.ts @@ -0,0 +1,9 @@ +import { CDP } from '../../engine/constants'; +import type { CdpConnection } from '../../engine/websocketTransport'; +import type { PageTargetInfo } from './types'; + +/** What the browser says of each tab now (its title, which isn't announced as it changes); a tab gone is left out. */ +export async function readTabInfos(connection: CdpConnection, ids: string[]): Promise { + const infos = await Promise.all(ids.map((targetId) => connection.send<{ targetInfo: PageTargetInfo }>(CDP.Target.getTargetInfo, { targetId }).then(({ targetInfo }) => targetInfo, () => null))); + return infos.filter((info) => info !== null); +} diff --git a/src/main/browsers/driven/releaseTab.ts b/src/main/browsers/driven/releaseTab.ts new file mode 100644 index 0000000..64411f0 --- /dev/null +++ b/src/main/browsers/driven/releaseTab.ts @@ -0,0 +1,7 @@ +import type { DrivenTabState } from './types'; + +/** Stops serving a tab: its interception goes, and its session is let go of. */ +export function releaseTab(tab: DrivenTabState): void { + tab.interception.detach(); + void tab.transport.detach(); +} diff --git a/src/main/browsers/driven/reloadTab.ts b/src/main/browsers/driven/reloadTab.ts new file mode 100644 index 0000000..fd3d0c7 --- /dev/null +++ b/src/main/browsers/driven/reloadTab.ts @@ -0,0 +1,8 @@ +import { CDP } from '../../engine/constants'; +import type { DrivenTabState } from './types'; + +/** Reloads a tab from the network, first asking service workers that run outdated code to unregister. */ +export async function reloadTab(tab: DrivenTabState): Promise { + await tab.interception.prepareReload(tab.info.url); + await tab.transport.send(CDP.Page.reload, { ignoreCache: true }); +} diff --git a/src/main/browsers/driven/shotBrowser.ts b/src/main/browsers/driven/shotBrowser.ts new file mode 100644 index 0000000..81172bb --- /dev/null +++ b/src/main/browsers/driven/shotBrowser.ts @@ -0,0 +1,8 @@ +import type { ShotBrowser } from '../../../shared/types'; +import type { DrivenChromium } from './DrivenChromium'; + +/** A driven browser, as the browser a capture was taken in. */ +export function shotBrowser(driven: DrivenChromium): ShotBrowser { + const { id, name, version } = driven.list(); + return { id, name, version }; +} diff --git a/src/main/ipc/registerShotIpc.ts b/src/main/ipc/registerShotIpc.ts index 097080c..11e25e9 100644 --- a/src/main/ipc/registerShotIpc.ts +++ b/src/main/ipc/registerShotIpc.ts @@ -3,7 +3,7 @@ import { copyFile } from 'node:fs/promises'; import { BrowserWindow, dialog, shell, type BrowserWindow as Window } from 'electron'; import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { AppEvent, CaptureArea } from '../../shared/types'; -import { copyShotImage, importDesignFiles, type PageShots } from '../shots'; +import { captureInEveryBrowser, copyShotImage, importDesignFiles, type PageShots } from '../shots'; import type { DrivenBrowsers } from '../browsers'; import type { ShotStore } from '../store/ShotStore'; import { assertString } from './assertString'; @@ -28,6 +28,7 @@ export function registerShotIpc(handle: IpcHandle, handlePage: IpcHandle, { win, handlePage(IPC_CHANNEL.listShots, () => shots.list()); handlePage(IPC_CHANNEL.captureShot, (area: unknown) => shots.capture(area)); handle(IPC_CHANNEL.captureElementShot, (pickId: unknown) => shots.captureElement(pickId)); + handlePage(IPC_CHANNEL.captureInEveryBrowser, () => captureInEveryBrowser(shots, driven)); handlePage(IPC_CHANNEL.captureTabShot, async (browserId: unknown, tabId: unknown, area: unknown) => { assertString(browserId, 'browserId'); assertString(tabId, 'tabId'); diff --git a/src/main/shots/PageShots/PageShots.ts b/src/main/shots/PageShots/PageShots.ts index c6eaedc..9426921 100644 --- a/src/main/shots/PageShots/PageShots.ts +++ b/src/main/shots/PageShots/PageShots.ts @@ -43,6 +43,11 @@ export class PageShots { return this.captureApp({ area: area as 'viewport' | 'page' }); } + /** Captures the whole page as the first of a group (captures taken together, in every browser). */ + captureInGroup(group: string): Promise { + return this.captureApp({ area: 'page' }, undefined, group); + } + /** Captures a pick's element (in any frame), without the inspector's highlight on it. */ async captureElement(pickId: unknown): Promise { const { inspector } = this.deps.page.frames; @@ -107,13 +112,13 @@ export class PageShots { } /** Captures the app's page once it is on screen, through `around` (a width to lay it out at) when given. */ - private async captureApp(target: CaptureTarget, around?: (capture: () => Promise) => Promise): Promise { + private async captureApp(target: CaptureTarget, around?: (capture: () => Promise) => Promise, group: string | null = null): Promise { const { page } = this.deps; const { url } = page.state(); if (!url) throw new Error('Open a page first'); await waitUntilShown(page.view); const capture = () => captureOverCdp(page.cdp, target); const image = await this.overlay.suspended(() => (around ? around(capture) : capture())); - return this.keep(image, url, target.area, { ...APP_BROWSER, version: process.versions.chrome }); + return this.keep(image, url, target.area, { ...APP_BROWSER, version: process.versions.chrome }, group); } } diff --git a/src/main/shots/capture/atViewport.ts b/src/main/shots/capture/atViewport.ts new file mode 100644 index 0000000..d1ceb1b --- /dev/null +++ b/src/main/shots/capture/atViewport.ts @@ -0,0 +1,19 @@ +import { CDP } from '../../engine/constants'; +import type { CdpTransport } from '../../engine/cdp'; +import { SETTLE_EXPRESSION } from '../constants'; +import type { Viewport } from './types'; + +/** + * Runs `task` with the page's window `width` × `height` CSS pixels at `scale` device pixels per CSS pixel, then gives + * the page its own size back. The page gets a resize, as a window would, and `task` starts once it has drawn at that + * size and its fonts are in. + */ +export async function atViewport(transport: CdpTransport, { width, height, scale }: Viewport, task: () => Promise): Promise { + await transport.send(CDP.Emulation.setDeviceMetricsOverride, { width, height, deviceScaleFactor: scale, mobile: false }); + try { + await transport.send(CDP.Runtime.evaluate, { expression: SETTLE_EXPRESSION, awaitPromise: true }).catch(() => undefined); + return await task(); + } finally { + await transport.send(CDP.Emulation.clearDeviceMetricsOverride).catch(() => undefined); + } +} diff --git a/src/main/shots/capture/atWidth.ts b/src/main/shots/capture/atWidth.ts index 11ca382..dc2f74e 100644 --- a/src/main/shots/capture/atWidth.ts +++ b/src/main/shots/capture/atWidth.ts @@ -1,20 +1,12 @@ -import { CDP } from '../../engine/constants'; import type { CdpTransport } from '../../engine/cdp'; -import { SETTLE_EXPRESSION } from '../constants'; +import { atViewport } from './atViewport'; import { windowMetrics } from './windowMetrics'; /** * Runs `task` with the page laid out `width` CSS pixels wide at `scale` device pixels per CSS pixel (a design's), its - * window as tall as it is, then gives the page its own size back. The page gets a resize, as a window would, and - * `task` starts once it has drawn at that size and its fonts are in. + * window as tall as it is (see {@link atViewport}). */ export async function atWidth(transport: CdpTransport, width: number, scale: number, task: () => Promise): Promise { const { height } = await windowMetrics(transport); - await transport.send(CDP.Emulation.setDeviceMetricsOverride, { width, height: height || width, deviceScaleFactor: scale, mobile: false }); - try { - await transport.send(CDP.Runtime.evaluate, { expression: SETTLE_EXPRESSION, awaitPromise: true }).catch(() => undefined); - return await task(); - } finally { - await transport.send(CDP.Emulation.clearDeviceMetricsOverride).catch(() => undefined); - } + return atViewport(transport, { width, height: height || width, scale }, task); } diff --git a/src/main/shots/capture/index.ts b/src/main/shots/capture/index.ts index 81d1f7f..1b61cf9 100644 --- a/src/main/shots/capture/index.ts +++ b/src/main/shots/capture/index.ts @@ -1,3 +1,4 @@ +export { atViewport } from './atViewport'; export { atWidth } from './atWidth'; export { captureOverCdp } from './captureOverCdp'; -export type { CapturedImage, CaptureTarget } from './types'; +export type { BrowserCapture, CapturedImage, CaptureTarget, Viewport } from './types'; diff --git a/src/main/shots/capture/types.ts b/src/main/shots/capture/types.ts index f5e01d2..3fd58de 100644 --- a/src/main/shots/capture/types.ts +++ b/src/main/shots/capture/types.ts @@ -1,4 +1,4 @@ -import type { Rect } from '../../../shared/types'; +import type { Rect, ShotBrowser } from '../../../shared/types'; /** What to capture: what the viewport shows, the whole page, or a box of it (an element's, in the top viewport's CSS pixels). */ export type CaptureTarget = { area: 'viewport' } | { area: 'page' } | { area: 'element'; box: Rect }; @@ -14,6 +14,16 @@ export interface CapturedImage { viewport: { width: number; height: number }; } +/** A capture taken in another browser, with the address it showed; or which browser failed, and why. */ +export type BrowserCapture = { image: CapturedImage; url: string; browser: ShotBrowser } | { browser: string; reason: string }; + +/** A window to lay a page out in: its size in CSS pixels, and device pixels per CSS pixel. */ +export interface Viewport { + width: number; + height: number; + scale: number; +} + /** What `Page.getLayoutMetrics` says, in CSS pixels. */ export interface LayoutMetrics { cssLayoutViewport: { pageX: number; pageY: number; clientWidth: number; clientHeight: number }; diff --git a/src/main/shots/captureInEveryBrowser.ts b/src/main/shots/captureInEveryBrowser.ts new file mode 100644 index 0000000..6bb9ae7 --- /dev/null +++ b/src/main/shots/captureInEveryBrowser.ts @@ -0,0 +1,27 @@ +import { randomBytes } from 'node:crypto'; +import type { GroupCapture, Shot } from '../../shared/types'; +import type { BrowserCapture, Viewport } from './capture'; +import { GROUP_ID_BYTES } from './constants'; +import type { PageShots } from './PageShots'; + +/** The browsers the app drives, capturing a page in each at once. */ +interface OtherBrowsers { + captureAt(url: string, viewport: Viewport): Promise; +} + +/** + * Captures the whole page in the app, then at the same address in every browser the app drives, laid out at the app's + * viewport and density: kept as one group, the app's capture first. A browser that fails is said, not thrown. + */ +export async function captureInEveryBrowser(shots: PageShots, others: OtherBrowsers): Promise { + const group = randomBytes(GROUP_ID_BYTES).toString('hex'); + const app = await shots.captureInGroup(group); + const taken = await others.captureAt(app.pageUrl ?? '', { width: app.viewport?.width ?? 0, height: app.viewport?.height ?? 0, scale: app.scale }); + const kept: Shot[] = [app]; + const failed: GroupCapture['failed'] = []; + for (const capture of taken) { + if ('reason' in capture) failed.push(capture); + else kept.push(await shots.keep(capture.image, capture.url, 'page', capture.browser, group)); + } + return { group, shots: kept, failed }; +} diff --git a/src/main/shots/constants.ts b/src/main/shots/constants.ts index e703666..1e0fa3b 100644 --- a/src/main/shots/constants.ts +++ b/src/main/shots/constants.ts @@ -1,3 +1,5 @@ +import { APP_BROWSER_ID } from '../../shared/constants'; + /** * Chromium's largest texture side, in device pixels: a capture taller than this comes back cut or blank, so a full * page is captured down to it. @@ -32,10 +34,19 @@ export const MAX_NAME_STEM = 80; export const PAGE_AREAS: ReadonlySet = new Set(['viewport', 'page']); /** The app's own page, as the browser a capture was taken in. */ -export const APP_BROWSER = { id: 'app', name: 'Chromium' } as const; +export const APP_BROWSER = { id: APP_BROWSER_ID, name: 'Chromium' } as const; /** What a design brought in without a name (pasted) is called, before its extension. */ export const DESIGN_NAME = 'design'; +/** How long a page captured in every browser has to be quiet after its load, before it is captured. */ +const SETTLE_QUIET_MS = 500; + +/** Waits for the page's load, then a quiet moment (run in its main world, awaited). */ +export const LOADED_EXPRESSION = `new Promise((resolve) => (document.readyState === 'complete' ? resolve() : addEventListener('load', () => resolve(), { once: true }))).then(() => new Promise((resolve) => setTimeout(resolve, ${SETTLE_QUIET_MS})))`; + +/** Bytes of a group's id (captures taken together, in every browser). */ +export const GROUP_ID_BYTES = 4; + /** Waits for the page to have drawn twice and its fonts to be in (run in its main world, awaited). */ export const SETTLE_EXPRESSION = 'document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))'; diff --git a/src/main/shots/index.ts b/src/main/shots/index.ts index bd48de3..0e64210 100644 --- a/src/main/shots/index.ts +++ b/src/main/shots/index.ts @@ -1,3 +1,4 @@ +export { captureInEveryBrowser } from './captureInEveryBrowser'; export { copyShotImage } from './copyShotImage'; export { importDesignFiles } from './importDesignFiles'; export { PageShots } from './PageShots'; diff --git a/src/preload/shotsApi.ts b/src/preload/shotsApi.ts index 6073a55..ff2efb5 100644 --- a/src/preload/shotsApi.ts +++ b/src/preload/shotsApi.ts @@ -8,6 +8,7 @@ export const shotsApi: ShotsApi = { captureShot: (area) => ipcRenderer.invoke(IPC_CHANNEL.captureShot, area), captureElementShot: (pickId) => ipcRenderer.invoke(IPC_CHANNEL.captureElementShot, pickId), captureTabShot: (browserId, tabId, area) => ipcRenderer.invoke(IPC_CHANNEL.captureTabShot, browserId, tabId, area), + captureInEveryBrowser: () => ipcRenderer.invoke(IPC_CHANNEL.captureInEveryBrowser), readShot: (id) => ipcRenderer.invoke(IPC_CHANNEL.readShot, id), renameShot: (id, name) => ipcRenderer.invoke(IPC_CHANNEL.renameShot, id, name), deleteShot: (id) => ipcRenderer.invoke(IPC_CHANNEL.deleteShot, id), diff --git a/src/renderer/src/app/model/bridge/commands/activeSavers.ts b/src/renderer/src/app/model/bridge/commands/activeSavers.ts index a614f04..1178b8d 100644 --- a/src/renderer/src/app/model/bridge/commands/activeSavers.ts +++ b/src/renderer/src/app/model/bridge/commands/activeSavers.ts @@ -12,4 +12,5 @@ export const ACTIVE_SAVERS: ActiveSavers = { 'new-rule': (id) => void createRulePage(id), shot: () => undefined, compare: () => undefined, + group: () => undefined, }; diff --git a/src/renderer/src/entities/browser/index.ts b/src/renderer/src/entities/browser/index.ts index 470a54d..8902283 100644 --- a/src/renderer/src/entities/browser/index.ts +++ b/src/renderer/src/entities/browser/index.ts @@ -1,2 +1,2 @@ -export { selectShownBrowsers, useBrowserStore } from './model/store'; +export { selectDrivenTabCount, selectShownBrowsers, useBrowserStore } from './model/store'; export { BrowserIcon } from './ui/BrowserIcon'; diff --git a/src/renderer/src/entities/browser/model/store/index.ts b/src/renderer/src/entities/browser/model/store/index.ts index 64387bd..a272bde 100644 --- a/src/renderer/src/entities/browser/model/store/index.ts +++ b/src/renderer/src/entities/browser/model/store/index.ts @@ -1,3 +1,4 @@ +export { selectDrivenTabCount } from './selectDrivenTabCount'; export { selectShownBrowsers } from './selectShownBrowsers'; export type { BrowserStore } from './types'; export { useBrowserStore } from './useBrowserStore'; diff --git a/src/renderer/src/entities/browser/model/store/selectDrivenTabCount.ts b/src/renderer/src/entities/browser/model/store/selectDrivenTabCount.ts new file mode 100644 index 0000000..f23e808 --- /dev/null +++ b/src/renderer/src/entities/browser/model/store/selectDrivenTabCount.ts @@ -0,0 +1,4 @@ +import type { BrowserStore } from './types'; + +/** How many tabs of driven browsers are served the workspace's changes. */ +export const selectDrivenTabCount = (s: BrowserStore): number => s.driven.reduce((sum, browser) => sum + browser.tabs.length, 0); diff --git a/src/renderer/src/entities/editor-tab/model/store/constants.ts b/src/renderer/src/entities/editor-tab/model/store/constants.ts index f96aeea..9ddd9ba 100644 --- a/src/renderer/src/entities/editor-tab/model/store/constants.ts +++ b/src/renderer/src/entities/editor-tab/model/store/constants.ts @@ -1,4 +1,4 @@ import type { PageKind, PageScope } from './types'; /** Which pages belong to the workspace shown: they close when it changes. */ -export const PAGE_SCOPES = { 'whats-new': 'app', stack: 'app', component: 'workspace', rule: 'workspace', 'new-rule': 'workspace', shot: 'workspace', compare: 'workspace' } as const satisfies Record; +export const PAGE_SCOPES = { 'whats-new': 'app', stack: 'app', component: 'workspace', rule: 'workspace', 'new-rule': 'workspace', shot: 'workspace', compare: 'workspace', group: 'workspace' } as const satisfies Record; diff --git a/src/renderer/src/entities/editor-tab/model/store/pageDirtyChecks.ts b/src/renderer/src/entities/editor-tab/model/store/pageDirtyChecks.ts index af30b23..dd07fc2 100644 --- a/src/renderer/src/entities/editor-tab/model/store/pageDirtyChecks.ts +++ b/src/renderer/src/entities/editor-tab/model/store/pageDirtyChecks.ts @@ -9,4 +9,5 @@ export const PAGE_DIRTY_CHECKS: { [K in PageKind]: (page: PageTabOf) => boole 'new-rule': (page) => !!page.dirty, shot: () => false, compare: () => false, + group: () => false, }; diff --git a/src/renderer/src/entities/editor-tab/model/store/types.ts b/src/renderer/src/entities/editor-tab/model/store/types.ts index 7b90503..fdef558 100644 --- a/src/renderer/src/entities/editor-tab/model/store/types.ts +++ b/src/renderer/src/entities/editor-tab/model/store/types.ts @@ -74,7 +74,9 @@ export type PageTab = /** A capture or design of the workspace. */ | (PageTabBase & { page: 'shot'; shotId: string }) /** Two captures or designs compared: `baseId` below (a design, usually), `otherId` over it. */ - | (PageTabBase & { page: 'compare'; baseId: string; otherId: string }); + | (PageTabBase & { page: 'compare'; baseId: string; otherId: string }) + /** Captures taken together in every browser, compared with one of them or a design. */ + | (PageTabBase & { page: 'group'; groupId: string }); export type PageKind = PageTab['page']; diff --git a/src/renderer/src/entities/shot/index.ts b/src/renderer/src/entities/shot/index.ts index 1c2aad3..6679b7c 100644 --- a/src/renderer/src/entities/shot/index.ts +++ b/src/renderer/src/entities/shot/index.ts @@ -1,3 +1,3 @@ -export { matchesShot, shotDetail, shotUrl, useMinute } from './lib'; +export { browserLabel, matchesShot, shotDetail, shotUrl, useMinute } from './lib'; export { useShotStore } from './model/store'; export { ShotThumb } from './ui/ShotThumb'; diff --git a/src/renderer/src/entities/shot/lib/browserLabel.ts b/src/renderer/src/entities/shot/lib/browserLabel.ts new file mode 100644 index 0000000..7c4e278 --- /dev/null +++ b/src/renderer/src/entities/shot/lib/browserLabel.ts @@ -0,0 +1,7 @@ +import type { ShotBrowser } from '@common/types'; + +/** The browser a capture was taken in, with its major version: `Firefox 143`. */ +export function browserLabel(browser: ShotBrowser): string { + const major = browser.version?.split('.')[0]; + return `${browser.name}${major ? ` ${major}` : ''}`; +} diff --git a/src/renderer/src/entities/shot/lib/index.ts b/src/renderer/src/entities/shot/lib/index.ts index 399dc1d..d57a775 100644 --- a/src/renderer/src/entities/shot/lib/index.ts +++ b/src/renderer/src/entities/shot/lib/index.ts @@ -1,3 +1,4 @@ +export { browserLabel } from './browserLabel'; export { matchesShot } from './matchesShot'; export { shotDetail } from './shotDetail'; export { shotUrl } from './shotUrl'; diff --git a/src/renderer/src/entities/shot/lib/shotDetail.ts b/src/renderer/src/entities/shot/lib/shotDetail.ts index 7b5bb9a..289a71a 100644 --- a/src/renderer/src/entities/shot/lib/shotDetail.ts +++ b/src/renderer/src/entities/shot/lib/shotDetail.ts @@ -1,4 +1,5 @@ import type { Shot } from '@common/types'; +import { browserLabel } from './browserLabel'; import { KIND_LABEL } from './constants'; import { timeAgo } from './timeAgo'; @@ -8,8 +9,7 @@ import { timeAgo } from './timeAgo'; */ export function shotDetail(shot: Shot, now: number): string { const { browser, viewport } = shot; - const major = browser?.version?.split('.')[0]; - const where = browser ? `${browser.name}${major ? ` ${major}` : ''}` : null; + const where = browser ? browserLabel(browser) : null; const size = viewport ? `${viewport.width} × ${viewport.height}` : `${Math.round(shot.width / shot.scale)} wide`; return [KIND_LABEL[shot.kind], where, size, timeAgo(shot.createdAt, now)].filter(Boolean).join(' · '); } diff --git a/src/renderer/src/features/shot/capture/index.ts b/src/renderer/src/features/shot/capture/index.ts index 17d37f4..7a121ef 100644 --- a/src/renderer/src/features/shot/capture/index.ts +++ b/src/renderer/src/features/shot/capture/index.ts @@ -1 +1 @@ -export { captureShot, pickingStarted, pickToCapture, takePickCapture } from './model'; +export { captureEverywhere, captureShot, pickingStarted, pickToCapture, takePickCapture } from './model'; diff --git a/src/renderer/src/features/shot/capture/model/captureEverywhere.ts b/src/renderer/src/features/shot/capture/model/captureEverywhere.ts new file mode 100644 index 0000000..f56050a --- /dev/null +++ b/src/renderer/src/features/shot/capture/model/captureEverywhere.ts @@ -0,0 +1,26 @@ +import type { GroupCapture } from '@common/types'; +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; + +/** + * Captures the whole page in the app and in every browser driven with the workspace's changes, as one group; the notice + * says which failed, and offers to open the group. Resolves with it, or null when the app's own capture failed. + */ +export async function captureEverywhere(onOpen?: (capture: GroupCapture) => void): Promise { + try { + const capture = await api.captureInEveryBrowser(); + const browsers = capture.shots.length; + const failed = capture.failed.map((f) => `${f.browser}: ${f.reason}`).join('\n'); + const alone = browsers === 1 && !failed ? 'Open the page in a browser with your changes to capture it there too.' : undefined; + toast({ + title: browsers === 1 ? 'Captured the whole page here only' : `Captured the whole page in ${browsers} browsers`, + description: failed || alone, + tone: failed ? 'warning' : 'success', + ...(onOpen && browsers > 1 ? { action: { label: 'Compare', onClick: () => onOpen(capture) } } : {}), + }); + return capture; + } catch (err) { + toast({ title: 'Could not capture the page in every browser', description: errorMessage(err), tone: 'danger' }); + return null; + } +} diff --git a/src/renderer/src/features/shot/capture/model/index.ts b/src/renderer/src/features/shot/capture/model/index.ts index ce29d38..695b3d0 100644 --- a/src/renderer/src/features/shot/capture/model/index.ts +++ b/src/renderer/src/features/shot/capture/model/index.ts @@ -1,3 +1,4 @@ +export { captureEverywhere } from './captureEverywhere'; export { captureShot } from './captureShot'; export { pickingStarted } from './pickingStarted'; export { pickToCapture } from './pickToCapture'; diff --git a/src/renderer/src/features/shot/compare/index.ts b/src/renderer/src/features/shot/compare/index.ts index 6fcf98c..3fe55b5 100644 --- a/src/renderer/src/features/shot/compare/index.ts +++ b/src/renderer/src/features/shot/compare/index.ts @@ -1 +1 @@ -export { compareWithPage, openCompare, useImageDiff, type DiffSource, type DiffState } from './model'; +export { compareWithPage, openCompare, openGroup, useImageDiff, type DiffSource, type DiffState } from './model'; diff --git a/src/renderer/src/features/shot/compare/model/constants.ts b/src/renderer/src/features/shot/compare/model/constants.ts index 263d2a6..b125c8e 100644 --- a/src/renderer/src/features/shot/compare/model/constants.ts +++ b/src/renderer/src/features/shot/compare/model/constants.ts @@ -3,3 +3,6 @@ export const DIFF_DEBOUNCE_MS = 150; /** A comparison page's tab id: this prefix, then both shots' ids. */ export const COMPARE_PAGE_PREFIX = 'compare:'; + +/** A group's page (captures taken together in every browser): this prefix, then the group's id. */ +export const GROUP_PAGE_PREFIX = 'group:'; diff --git a/src/renderer/src/features/shot/compare/model/index.ts b/src/renderer/src/features/shot/compare/model/index.ts index 4424de0..6bf1782 100644 --- a/src/renderer/src/features/shot/compare/model/index.ts +++ b/src/renderer/src/features/shot/compare/model/index.ts @@ -1,4 +1,5 @@ export { compareWithPage } from './compareWithPage'; export { openCompare } from './openCompare'; +export { openGroup } from './openGroup'; export type { DiffSource, DiffState } from './types'; export { useImageDiff } from './useImageDiff'; diff --git a/src/renderer/src/features/shot/compare/model/openGroup.ts b/src/renderer/src/features/shot/compare/model/openGroup.ts new file mode 100644 index 0000000..4c6ef90 --- /dev/null +++ b/src/renderer/src/features/shot/compare/model/openGroup.ts @@ -0,0 +1,11 @@ +import type { Shot } from '@common/types'; +import { hostOf } from '@/shared/lib'; +import { useTabStore } from '@/entities/editor-tab'; +import { GROUP_PAGE_PREFIX } from './constants'; + +/** Opens (or switches to) the page comparing the captures taken together with `shot`, in every browser. */ +export function openGroup(shot: Pick): void { + if (!shot.group) return; + const title = `Every browser · ${hostOf(shot.pageUrl ?? '') || 'page'}`; + useTabStore.getState().openPage({ id: `${GROUP_PAGE_PREFIX}${shot.group}`, page: 'group', title, groupId: shot.group }); +} diff --git a/src/renderer/src/features/shot/open-shot/model/receiveShots.ts b/src/renderer/src/features/shot/open-shot/model/receiveShots.ts index d893b0b..20985da 100644 --- a/src/renderer/src/features/shot/open-shot/model/receiveShots.ts +++ b/src/renderer/src/features/shot/open-shot/model/receiveShots.ts @@ -3,8 +3,8 @@ import { useTabStore } from '@/entities/editor-tab'; import { useShotStore } from '@/entities/shot'; /** - * Takes the workspace's shots as announced: pages of shots gone close (and comparisons with one), and renamed ones - * take their new name. + * Takes the workspace's shots as announced: pages of shots gone close (and comparisons with one, and groups with none + * left), and renamed ones take their new name. */ export function receiveShots(shots: Shot[]): void { useShotStore.getState().setAll(shots); @@ -12,7 +12,9 @@ export function receiveShots(shots: Shot[]): void { const byId = new Map(shots.map((s) => [s.id, s])); const pages = tabs.pages.filter((p) => p.page === 'shot'); const compares = tabs.pages.filter((p) => p.page === 'compare'); - tabs.removePages([...pages.filter((p) => !byId.has(p.shotId)), ...compares.filter((p) => !byId.has(p.baseId) || !byId.has(p.otherId))].map((p) => p.id)); + const groups = new Set(shots.map((s) => s.group)); + const gone = tabs.pages.filter((p) => p.page === 'group' && !groups.has(p.groupId)); + tabs.removePages([...pages.filter((p) => !byId.has(p.shotId)), ...compares.filter((p) => !byId.has(p.baseId) || !byId.has(p.otherId)), ...gone].map((p) => p.id)); for (const page of pages) { const shot = byId.get(page.shotId); if (shot && shot.name !== page.title) tabs.retitlePage(page.id, shot.name); diff --git a/src/renderer/src/widgets/command-palette/ui/shotItems.ts b/src/renderer/src/widgets/command-palette/ui/shotItems.ts index 2173279..bc97c74 100644 --- a/src/renderer/src/widgets/command-palette/ui/shotItems.ts +++ b/src/renderer/src/widgets/command-palette/ui/shotItems.ts @@ -1,13 +1,14 @@ import { icons } from '@/shared/config'; import type { CommandItem } from '@/shared/ui/command-palette'; -import { captureShot, pickToCapture } from '@/features/shot/capture'; +import { captureEverywhere, captureShot, pickToCapture } from '@/features/shot/capture'; +import { openGroup } from '@/features/shot/compare'; import { importDesigns } from '@/features/shot/import-design'; import { openShot } from '@/features/shot/open-shot'; /** Keywords of every capture item. */ const CAPTURE_KEYWORDS = ['screenshot', 'capture', 'image', 'png']; -/** Capturing the page shown (what it shows, all of it, or an element picked in it), and importing designs. */ +/** Capturing the page shown (what it shows, all of it, an element picked in it, or in every browser), and importing designs. */ export function shotItems(hasPage: boolean): CommandItem[] { const importItem: CommandItem = { id: 'import-designs', label: 'Import designs…', icon: icons.ImportDesignIcon, keywords: ['design', 'mockup', 'figma', 'pixel perfect', 'image'], onSelect: () => void importDesigns(openShot) }; if (!hasPage) return [importItem]; @@ -15,6 +16,13 @@ export function shotItems(hasPage: boolean): CommandItem[] { { id: 'capture-viewport', label: 'Capture the page', icon: icons.CaptureIcon, keywords: CAPTURE_KEYWORDS, onSelect: () => void captureShot('viewport', null, openShot) }, { id: 'capture-page', label: 'Capture the whole page', icon: icons.FullPageIcon, keywords: [...CAPTURE_KEYWORDS, 'full page'], onSelect: () => void captureShot('page', null, openShot) }, { id: 'capture-element', label: 'Capture an element…', icon: icons.PickIcon, keywords: [...CAPTURE_KEYWORDS, 'pick', 'component'], onSelect: () => void pickToCapture() }, + { + id: 'capture-every-browser', + label: 'Capture in every browser', + icon: icons.BrowserIcon, + keywords: [...CAPTURE_KEYWORDS, 'browsers', 'cross-browser', 'compare'], + onSelect: () => void captureEverywhere(({ shots: [first] }) => openGroup(first)), + }, importItem, ]; } diff --git a/src/renderer/src/widgets/editor-panel/ui/EditorPanel/pageTabIcons.ts b/src/renderer/src/widgets/editor-panel/ui/EditorPanel/pageTabIcons.ts index a66d146..7bb776a 100644 --- a/src/renderer/src/widgets/editor-panel/ui/EditorPanel/pageTabIcons.ts +++ b/src/renderer/src/widgets/editor-panel/ui/EditorPanel/pageTabIcons.ts @@ -11,4 +11,5 @@ export const PAGE_TAB_ICONS: Record = { 'new-rule': { icon: icons.RulesIcon, className: 'text-fg-muted' }, shot: { icon: icons.ShotIcon, className: 'text-info' }, compare: { icon: icons.DiffIcon, className: 'text-info' }, + group: { icon: icons.BrowserIcon, className: 'text-info' }, }; diff --git a/src/renderer/src/widgets/editor-panel/ui/EditorPanel/pageViews.ts b/src/renderer/src/widgets/editor-panel/ui/EditorPanel/pageViews.ts index 69ff7dc..46b0e90 100644 --- a/src/renderer/src/widgets/editor-panel/ui/EditorPanel/pageViews.ts +++ b/src/renderer/src/widgets/editor-panel/ui/EditorPanel/pageViews.ts @@ -1,5 +1,6 @@ import { ComparePage } from '../ComparePage'; import { ComponentPage } from '../ComponentPage'; +import { GroupPage } from '../GroupPage'; import { NewRulePage, RulePage } from '../RulePage'; import { ShotPage } from '../ShotPage'; import { StackPage } from '../StackPage'; @@ -15,4 +16,5 @@ export const PAGE_VIEWS: PageViews = { 'new-rule': NewRulePage, shot: ShotPage, compare: ComparePage, + group: GroupPage, }; diff --git a/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupCell.tsx b/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupCell.tsx new file mode 100644 index 0000000..b4ad8f1 --- /dev/null +++ b/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupCell.tsx @@ -0,0 +1,47 @@ +import type { Shot } from '@common/types'; +import { Spinner } from '@/shared/ui/spinner'; +import { shotUrl } from '@/entities/shot'; +import { openCompare, useImageDiff } from '@/features/shot/compare'; +import { DiffCanvas } from '../ComparePage/DiffCanvas'; +import { differingShare } from '../ComparePage/differingShare'; +import { sourceOf } from '../ComparePage/sourceOf'; +import { NO_OFFSET } from './constants'; +import { shotLabel } from './shotLabel'; +import type { GroupView } from './types'; + +export interface GroupCellProps { + shot: Shot; + base: Shot; + view: GroupView; +} + +/** A capture of the group: its image or its difference from the baseline, and how much differs; choosing it compares the two. */ +export function GroupCell({ shot, base, view }: GroupCellProps) { + const isBase = shot.id === base.id; + const diff = useImageDiff(sourceOf(base), sourceOf(shot), NO_OFFSET, !isBase); + const share = diff.status === 'done' ? `${differingShare(diff.differing, diff.total)} differ` : null; + return ( +
+ +
+ {shotLabel(shot)} + {isBase ? Baseline : null} + {!isBase && diff.status === 'failed' ? Could not compare : null} + {!isBase && (diff.status === 'working' || diff.status === 'idle') ? : null} + {share ? ( + + {share} + + ) : null} +
+
+ ); +} diff --git a/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupHeader.tsx b/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupHeader.tsx new file mode 100644 index 0000000..bc90f55 --- /dev/null +++ b/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupHeader.tsx @@ -0,0 +1,46 @@ +import type { Shot } from '@common/types'; +import { icons } from '@/shared/config'; +import { Button } from '@/shared/ui/button'; +import { Icon } from '@/shared/ui/icon'; +import { Menu, type MenuItem } from '@/shared/ui/menu'; +import { PaneTabs } from '@/shared/ui/pane-tabs'; +import { GROUP_VIEWS } from './constants'; +import { shotLabel } from './shotLabel'; +import type { GroupView } from './types'; + +export interface GroupHeaderProps { + /** The group's captures, the app's first. */ + members: Shot[]; + /** The workspace's designs, offered as the baseline too. */ + designs: Shot[]; + base: Shot; + onBase(id: string): void; + view: GroupView; + onView(view: GroupView): void; +} + +/** The group's address and viewport, what each capture is compared with (one of them, or a design), and what the cells show. */ +export function GroupHeader({ members, designs, base, onBase, view, onView }: GroupHeaderProps) { + const [first] = members; + const baseline = (shot: Shot): MenuItem => ({ label: shotLabel(shot), icon: shot.kind === 'design' ? icons.ImportDesignIcon : icons.BrowserIcon, checked: shot.id === base.id, onSelect: () => onBase(shot.id) }); + const items: MenuItem[] = [...members.map(baseline), ...(designs.length ? [{ separator: true } as const, ...designs.map(baseline)] : [])]; + return ( +
+
+

+ {first?.pageUrl} in {members.length === 1 ? '1 browser' : `${members.length} browsers`} +

+ {first?.viewport ? {`${first.viewport.width} × ${first.viewport.height} @${first.scale}×`} : null} +
+
+ tabs={GROUP_VIEWS} value={view} onChange={onView} label="Show" /> + + + + +
+
+ ); +} diff --git a/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupPage.tsx b/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupPage.tsx new file mode 100644 index 0000000..f2c995e --- /dev/null +++ b/src/renderer/src/widgets/editor-panel/ui/GroupPage/GroupPage.tsx @@ -0,0 +1,32 @@ +import { useState } from 'react'; +import type { PageTabOf } from '@/entities/editor-tab'; +import { useShotStore } from '@/entities/shot'; +import { GroupCell } from './GroupCell'; +import { GroupHeader } from './GroupHeader'; +import type { GroupView } from './types'; + +/** + * Captures taken together in every browser, side by side: each compared with a baseline (the app's capture at first, + * another of them, or a design), with the share of its pixels that differ. + */ +export function GroupPage({ page }: { page: PageTabOf<'group'> }) { + const shots = useShotStore((s) => s.shots); + const [baseId, setBaseId] = useState(null); + const [view, setView] = useState('captures'); + // Newest first in the store: the app's capture, taken first, leads. + const members = shots.filter((s) => s.group === page.groupId).reverse(); + const designs = shots.filter((s) => s.kind === 'design'); + const base = [...members, ...designs].find((s) => s.id === baseId) ?? members[0]; + if (!base) return
These captures were deleted.
; + + return ( +
+ +
+ {members.map((shot) => ( + + ))} +
+
+ ); +} diff --git a/src/renderer/src/widgets/editor-panel/ui/GroupPage/constants.ts b/src/renderer/src/widgets/editor-panel/ui/GroupPage/constants.ts new file mode 100644 index 0000000..4a095ef --- /dev/null +++ b/src/renderer/src/widgets/editor-panel/ui/GroupPage/constants.ts @@ -0,0 +1,11 @@ +import type { PaneTab } from '@/shared/ui/pane-tabs'; +import type { GroupView } from './types'; + +/** Captures in a group aren't moved against the baseline: they were taken at the same address and viewport. */ +export const NO_OFFSET = { x: 0, y: 0 } as const; + +/** What each cell shows: the captures, or how each differs from the baseline. */ +export const GROUP_VIEWS: readonly PaneTab[] = [ + { id: 'captures', label: 'Captures' }, + { id: 'differences', label: 'Differences' }, +]; diff --git a/src/renderer/src/widgets/editor-panel/ui/GroupPage/index.ts b/src/renderer/src/widgets/editor-panel/ui/GroupPage/index.ts new file mode 100644 index 0000000..7f36193 --- /dev/null +++ b/src/renderer/src/widgets/editor-panel/ui/GroupPage/index.ts @@ -0,0 +1 @@ +export { GroupPage } from './GroupPage'; diff --git a/src/renderer/src/widgets/editor-panel/ui/GroupPage/shotLabel.ts b/src/renderer/src/widgets/editor-panel/ui/GroupPage/shotLabel.ts new file mode 100644 index 0000000..a37a6cd --- /dev/null +++ b/src/renderer/src/widgets/editor-panel/ui/GroupPage/shotLabel.ts @@ -0,0 +1,9 @@ +import { APP_BROWSER_ID } from '@common/constants'; +import type { Shot } from '@common/types'; +import { browserLabel } from '@/entities/shot'; + +/** What a shot is called in a group: the browser it was taken in (the app's own page said so), or a design's name. */ +export function shotLabel(shot: Shot): string { + if (!shot.browser) return shot.name; + return shot.browser.id === APP_BROWSER_ID ? `This app · ${browserLabel(shot.browser)}` : browserLabel(shot.browser); +} diff --git a/src/renderer/src/widgets/editor-panel/ui/GroupPage/types.ts b/src/renderer/src/widgets/editor-panel/ui/GroupPage/types.ts new file mode 100644 index 0000000..decb41c --- /dev/null +++ b/src/renderer/src/widgets/editor-panel/ui/GroupPage/types.ts @@ -0,0 +1,2 @@ +/** What a group's cells show: the captures, or how each differs from the baseline. */ +export type GroupView = 'captures' | 'differences'; diff --git a/src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx b/src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx index de7b8f9..f765925 100644 --- a/src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx +++ b/src/renderer/src/widgets/editor-panel/ui/ShotPage/ShotActions.tsx @@ -2,13 +2,18 @@ import type { Shot } from '@common/types'; import { api } from '@/shared/api'; import { icons } from '@/shared/config'; import { IconButton } from '@/shared/ui/icon-button'; +import { openGroup } from '@/features/shot/compare'; import { copyShot, deleteShot, saveShotAs } from '@/features/shot/manage'; import { showOverlay } from '@/features/shot/overlay'; -/** What can be done with a shot from its page: lay it over the page, copy it, save a copy, show its file, delete it. */ +/** + * What can be done with a shot from its page: compare the captures taken with it in every browser, lay it over the + * page, copy it, save a copy, show its file, delete it. + */ export function ShotActions({ shot }: { shot: Shot }) { return (
+ {shot.group ? openGroup(shot)} data-testid="shot-group" /> : null} void showOverlay(shot)} data-testid="shot-overlay" /> void copyShot(shot)} /> void saveShotAs(shot)} /> diff --git a/src/renderer/src/widgets/page-preview/ui/ShotsMenu/CaptureMenu.tsx b/src/renderer/src/widgets/page-preview/ui/ShotsMenu/CaptureMenu.tsx index 9dedb39..908521b 100644 --- a/src/renderer/src/widgets/page-preview/ui/ShotsMenu/CaptureMenu.tsx +++ b/src/renderer/src/widgets/page-preview/ui/ShotsMenu/CaptureMenu.tsx @@ -9,14 +9,18 @@ export interface CaptureMenuProps { canPick: boolean; disabled: boolean; onCapture(area: CaptureArea): void; + /** Captures the whole page here and in every browser driven with the workspace's changes. */ + onCaptureEverywhere(): void; } -/** The shots menu's Capture: what the page shows, the whole page, or an element picked in it. */ -export function CaptureMenu({ canPick, disabled, onCapture }: CaptureMenuProps) { +/** The shots menu's Capture: what the page shows, the whole page, an element picked in it, or the page in every browser. */ +export function CaptureMenu({ canPick, disabled, onCapture, onCaptureEverywhere }: CaptureMenuProps) { const items: MenuItem[] = [ { label: 'What the page shows', icon: icons.CaptureIcon, onSelect: () => onCapture('viewport') }, { label: 'The whole page', icon: icons.FullPageIcon, onSelect: () => onCapture('page') }, ...(canPick ? [{ label: 'An element…', icon: icons.PickIcon, onSelect: () => onCapture('element') }] : []), + { separator: true }, + { label: 'In every browser', icon: icons.BrowserIcon, onSelect: onCaptureEverywhere }, ]; return ( diff --git a/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenu.tsx b/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenu.tsx index ef5361e..0177351 100644 --- a/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenu.tsx +++ b/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenu.tsx @@ -18,6 +18,7 @@ export interface ShotsMenuProps { /** In the editor: picking an element and deleting are offered. */ inEditor: boolean; onCapture(area: CaptureArea): void; + onCaptureEverywhere(): void; onOpen(shot: Shot): void; /** Import designs: from the system's dialog (null), or images dropped or pasted on the menu. */ onImport(files: File[] | null): void; @@ -27,7 +28,7 @@ export interface ShotsMenuProps { * The shots menu's content: capturing, importing designs (dropped or pasted too), a search, All · Captures · Designs, * and the workspace's shots, newest first. */ -export function ShotsMenu({ hasPage, inEditor, onCapture, onOpen, onImport }: ShotsMenuProps) { +export function ShotsMenu({ hasPage, inEditor, onCapture, onCaptureEverywhere, onOpen, onImport }: ShotsMenuProps) { const shots = useShotStore((s) => s.shots); const now = useMinute(); const [query, setQuery] = useState(''); @@ -50,7 +51,7 @@ export function ShotsMenu({ hasPage, inEditor, onCapture, onOpen, onImport }: Sh
setQuery(event.target.value)} placeholder="Search captures and designs…" aria-label="Search captures and designs" leading={} className="flex-1" /> onImport(null)} data-testid="shots-import" /> - +
tabs={SHOT_FILTERS} value={filter} onChange={setFilter} label="Show" /> diff --git a/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenuButton.tsx b/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenuButton.tsx index f6a3c21..ef31f53 100644 --- a/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenuButton.tsx +++ b/src/renderer/src/widgets/page-preview/ui/ShotsMenu/ShotsMenuButton.tsx @@ -4,7 +4,8 @@ import { api } from '@/shared/api'; import { Popover } from '@/shared/ui/popover'; import { Tooltip } from '@/shared/ui/tooltip'; import { useShotStore } from '@/entities/shot'; -import { captureShot, pickToCapture } from '@/features/shot/capture'; +import { captureEverywhere, captureShot, pickToCapture } from '@/features/shot/capture'; +import { openGroup } from '@/features/shot/compare'; import { addDesignFiles, importDesigns } from '@/features/shot/import-design'; import { openShot } from '@/features/shot/open-shot'; import { ShotStack } from './ShotStack'; @@ -29,6 +30,11 @@ export function ShotsMenuButton({ hasPage, inEditor }: ShotsMenuButtonProps) { if (area === 'element') void pickToCapture(); else void captureShot(area, null, show); }; + const captureGroup = () => { + setOpen(false); + // In the website's own window, the app's capture opens in the editor; its page leads to the group. + void captureEverywhere(({ shots: [first] }) => (inEditor ? openGroup(first) : show(first))); + }; return ( <> @@ -51,6 +57,7 @@ export function ShotsMenuButton({ hasPage, inEditor }: ShotsMenuButtonProps) { hasPage={hasPage} inEditor={inEditor} onCapture={capture} + onCaptureEverywhere={captureGroup} onOpen={(shot) => { setOpen(false); show(shot); diff --git a/src/renderer/src/widgets/status-bar/ui/DrivenChip.tsx b/src/renderer/src/widgets/status-bar/ui/DrivenChip.tsx new file mode 100644 index 0000000..95e8a4f --- /dev/null +++ b/src/renderer/src/widgets/status-bar/ui/DrivenChip.tsx @@ -0,0 +1,18 @@ +import { icons } from '@/shared/config'; +import { Counter } from '@/shared/ui/counter'; +import { Icon } from '@/shared/ui/icon'; +import { selectDrivenTabCount, useBrowserStore } from '@/entities/browser'; +import { ITEM_ICON_SIZE } from './constants'; + +/** How many tabs of other browsers are served the workspace's changes. Hidden while there are none. */ +export function DrivenChip() { + const tabs = useBrowserStore(selectDrivenTabCount); + const browsers = useBrowserStore((s) => s.driven.map((b) => b.name).join(', ')); + if (!tabs) return null; + return ( + + + {tabs === 1 ? 'tab' : 'tabs'} in other browsers + + ); +} diff --git a/src/renderer/src/widgets/status-bar/ui/StatusBar.tsx b/src/renderer/src/widgets/status-bar/ui/StatusBar.tsx index e7a0577..8e29403 100644 --- a/src/renderer/src/widgets/status-bar/ui/StatusBar.tsx +++ b/src/renderer/src/widgets/status-bar/ui/StatusBar.tsx @@ -13,6 +13,7 @@ import { THROTTLING_LABELS, useSettingsStore } from '@/entities/settings'; import { KIND_NAME, selectIframeCount, selectWorkerCount, useResourceStore } from '@/entities/resource'; import { UpdateStatus } from '@/features/update-app'; import { ITEM_ICON_SIZE } from './constants'; +import { DrivenChip } from './DrivenChip'; import { StackChip } from './StackChip'; /** Quiet one-line summary: page state, what is being served, the active file. */ @@ -52,6 +53,7 @@ export function StatusBar() { No overrides )} + {throttling !== NO_THROTTLING ? ( diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 6fc2956..6ddc632 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -117,5 +117,8 @@ export const MAX_URL_CHARS = 8192; */ export const SHOT_SCHEME = 'console-editor-shot'; +/** The browser id of captures of the app's own page (other browsers' are their installed ids). */ +export const APP_BROWSER_ID = 'app'; + /** A shot URL's host: the image itself, or its thumbnail. */ export const SHOT_URL_HOST = { image: 'image', thumb: 'thumb' } as const; diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts index e507260..71de86d 100644 --- a/src/shared/ipcChannels.ts +++ b/src/shared/ipcChannels.ts @@ -101,6 +101,7 @@ export const IPC_CHANNEL = { captureShot: 'shots:capture', captureElementShot: 'shots:capture-element', captureTabShot: 'shots:capture-tab', + captureInEveryBrowser: 'shots:capture-every', readShot: 'shots:read', renameShot: 'shots:rename', deleteShot: 'shots:delete', diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index f313f32..191e5d6 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -60,7 +60,7 @@ export { FILE_KINDS, RESOURCE_KINDS } from './resources'; export type { BlockRule, CorsRule, CreateRuleInput, HeaderEdit, HeaderOperation, HeaderRule, Rule, RuleAction, RuleBase, RuleOf, RulePatch, RuleResourceType } from './rules'; export { HEADER_OPERATIONS, RULE_ACTIONS, RULE_RESOURCE_TYPES } from './rules'; export type { SessionDraft, SessionState, SessionTab } from './session'; -export type { CaptureArea, DesignImport, Shot, ShotBrowser, ShotKind } from './shots'; +export type { CaptureArea, DesignImport, GroupCapture, Shot, ShotBrowser, ShotKind } from './shots'; export { CAPTURE_AREAS, SHOT_KINDS } from './shots'; export type { ShotsApi } from './shotsApi'; export type { SourceMapBody, SourceMapFetchFailure, SourceMapFile, SourceMapFileInfo, SourceMapKind, SourceMapRequest } from './sourceMaps'; diff --git a/src/shared/types/shots.ts b/src/shared/types/shots.ts index 6988a3c..f05fdb2 100644 --- a/src/shared/types/shots.ts +++ b/src/shared/types/shots.ts @@ -39,6 +39,13 @@ export interface Shot { updatedAt: number; } +/** What capturing in every browser did: the captures kept (one group, the app's first), and the browsers that failed. */ +export interface GroupCapture { + group: string; + shots: Shot[]; + failed: { browser: string; reason: string }[]; +} + /** What importing designs did: the ones kept, and the files that couldn't be (with why). */ export interface DesignImport { added: Shot[]; diff --git a/src/shared/types/shotsApi.ts b/src/shared/types/shotsApi.ts index e6fc34f..cd15b43 100644 --- a/src/shared/types/shotsApi.ts +++ b/src/shared/types/shotsApi.ts @@ -1,4 +1,4 @@ -import type { CaptureArea, DesignImport, Shot } from './shots'; +import type { CaptureArea, DesignImport, GroupCapture, Shot } from './shots'; /** Captures and designs' part of the API exposed to the renderer (`ConsoleEditorApi`). */ export interface ShotsApi { @@ -6,6 +6,11 @@ export interface ShotsApi { listShots(): Promise; /** Captures the page shown, what its viewport shows or all of it, and keeps it with the workspace. */ captureShot(area: Exclude): Promise; + /** + * Captures the whole page in the app and in every browser it drives with the workspace's changes, at the app's + * address, viewport and density, as one group. + */ + captureInEveryBrowser(): Promise; /** Captures a tab of a browser the app drives: what it shows, or all of it. */ captureTabShot(browserId: string, tabId: string, area: Exclude): Promise; /** Captures the element of a pick (the inspector's), in any frame. */ diff --git a/test/e2e/driven.e2e.test.ts b/test/e2e/driven.e2e.test.ts index 428d1be..31be981 100644 --- a/test/e2e/driven.e2e.test.ts +++ b/test/e2e/driven.e2e.test.ts @@ -1,7 +1,8 @@ /** * A Chromium browser driven with the workspace's changes, in the built app on Linux: a real Chromium installed as a * launcher is offered "with your changes" in the browser menu; the page opens there served the workspace's override, - * its tab is listed under the browser and captured into the shots, and letting go of it takes it off the menu. + * its tab is listed under the browser and captured into the shots, the page is captured here and there at once and + * the two compared, and letting go of it takes it off the menu. */ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -123,7 +124,30 @@ describe.skipIf(!built || !chromiumPath || process.platform !== 'linux')('A brow expect(shot).toMatchObject({ kind: 'capture', area: 'viewport', pageUrl: `${origin}/page.html`, browser: { id: BROWSER_ID, name: 'Test Chromium' } }); }); + it('captures the page here and in the browser at once, as a group, and compares them', async () => { + await win.keyboard.press('Escape'); + await expect.poll(() => menu().count()).toBe(0); + await win.getByTestId('shots-menu-button').click(); + await win.getByTestId('shots-capture').click(); + await win.getByRole('menuitem', { name: 'In every browser' }).click(); + await expect.poll(async () => (await shots()).filter((s) => s.group).length, { timeout: 30_000 }).toBe(2); + // Newest first: the app's capture was taken first. + const [other, own] = (await shots()).filter((s) => s.group); + expect(own).toMatchObject({ kind: 'capture', area: 'page', pageUrl: `${origin}/page.html`, browser: { id: 'app' } }); + expect(other).toMatchObject({ area: 'page', pageUrl: `${origin}/page.html`, group: own.group, browser: { id: BROWSER_ID }, viewport: own.viewport, scale: own.scale, width: own.width }); + + await win.getByRole('button', { name: 'Compare', exact: true }).click(); + await win.getByTestId('group-page').waitFor(); + await expect.poll(() => win.getByTestId('group-cell').count()).toBe(2); + expect(await win.getByTestId('group-cell').first().getAttribute('data-shot-id')).toBe(own.id); + await expect.poll(() => win.getByTestId('group-share').innerText(), { timeout: 20_000 }).toMatch(/% differ$/); + expect(await win.getByTestId('group-baseline').innerText()).toContain('This app'); + await win.getByRole('tab', { name: 'Differences' }).click(); + await expect.poll(() => win.getByTestId('group-cell').locator('canvas').count()).toBe(1); + }); + it('lets go of it, taking it off the menu', async () => { + await win.getByTestId('browser-menu-button').click(); await menu().getByRole('button', { name: 'Stop serving your changes in Test Chromium' }).click(); await expect.poll(() => driven()).toEqual([]); await expect.poll(() => menu().getByTestId('driven-browsers').count()).toBe(0); diff --git a/test/integration/driven.chromium.test.ts b/test/integration/driven.chromium.test.ts index ba06188..9d0a2c7 100644 --- a/test/integration/driven.chromium.test.ts +++ b/test/integration/driven.chromium.test.ts @@ -1,7 +1,7 @@ /** * A Chromium browser driven with the workspace's changes: launched with a profile of the app's own and a debugging * port, every tab served the overrides (a new tab from its first request), changes served and the tabs reloaded, a tab - * captured, the browser let go of (it stays open) and reached again rather than launched twice, and forgotten once + * captured (and at a viewport given, as in every browser at once), the browser let go of (it stays open) and reached again rather than launched twice, and forgotten once * it is quit. */ import { existsSync, readFileSync } from 'node:fs'; @@ -116,6 +116,17 @@ describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes await expect(driven.capture(browser.id, tab.id, 'element')).rejects.toThrow('Invalid capture area'); }); + it('captures the whole page at an address in every driven browser, laid out in a viewport given (the app\'s)', async () => { + const taken = await driven.captureAt(`${origin}/page.html`, { width: 400, height: 300, scale: 2 }); + expect(taken).toHaveLength(1); + const [capture] = taken; + if ('reason' in capture) throw new Error(capture.reason); + expect(capture).toMatchObject({ url: `${origin}/page.html`, browser: { id: browser.id, name: 'Test Chromium' } }); + expect(capture.image).toMatchObject({ width: 800, height: 600, scale: 2, viewport: { width: 400, height: 300 } }); + // The tab showing it was used: no other opened. + expect(tabs()).toHaveLength(2); + }); + it('lets go of it (it stays open), and reaches it again rather than launching another', async () => { const port = readFileSync(join(profile(), 'DevToolsActivePort'), 'utf8'); driven.stop(browser.id); diff --git a/test/renderer/pageTabs.test.ts b/test/renderer/pageTabs.test.ts index 246de59..2ed3b6e 100644 --- a/test/renderer/pageTabs.test.ts +++ b/test/renderer/pageTabs.test.ts @@ -92,7 +92,7 @@ describe('page tabs', () => { expect(isPageDirty(newRulePage('n1', EDITED))).toBe(true); expect(isPageDirty(STACK)).toBe(false); expect(isPageDirty(COMPONENT)).toBe(false); - expect(PAGE_SCOPES).toEqual({ 'whats-new': 'app', stack: 'app', rule: 'workspace', 'new-rule': 'workspace', component: 'workspace', shot: 'workspace', compare: 'workspace' }); + expect(PAGE_SCOPES).toEqual({ 'whats-new': 'app', stack: 'app', rule: 'workspace', 'new-rule': 'workspace', component: 'workspace', shot: 'workspace', compare: 'workspace', group: 'workspace' }); }); it("closes the workspace's rule and new-rule pages with its tabs; What's New and the Page stack stay", () => { From 8e6202ebe8e102f0e0af411b4adaba7403681a13 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 22:26:10 +0000 Subject: [PATCH 08/19] Open the page in Firefox with the workspace's changes Firefox can now be launched with a profile of the app's own and driven over WebDriver BiDi, like the Chromium browsers over CDP. One interception covers every tab and applies the engine's matching and rules to each paused request: - a block rule fails the request; - an override answers before the request is sent, since Firefox can only replace a body then. The response has its kind's content type, a response override's status, headers and delay, and is readable cross-origin; - the CORS preflight ahead of such a request is allowed; - header and CORS rules edit the upstream response's head. Requests are paused only while an override or a rule is on, and the cache is bypassed then. Tabs are listed and captured, including in "Capture in every browser", and the app reconnects to a Firefox that is still open. The drivers share one interface, a launch path and their tab list. The engine-neutral part of deciding what to do with a request (used by Firefox) now lives in the engine's answering module. The browsers end-to-end test picks the plain "Open in" palette entry by its exact name, now that "with your changes" follows it. --- src/main/browsers/driven/DrivenBase.ts | 50 +++++++ src/main/browsers/driven/DrivenBrowsers.ts | 56 ++++--- src/main/browsers/driven/DrivenTabs.ts | 70 ++++++--- .../driven/{ => chromium}/DrivenChromium.ts | 79 +++++----- .../driven/{ => chromium}/attachTab.ts | 9 +- .../driven/{ => chromium}/captureTabAt.ts | 10 +- .../driven/chromium/connectChromium.ts | 20 +++ .../browsers/driven/chromium/constants.ts | 22 +++ .../driven/chromium/launchChromium.ts | 11 ++ .../driven/{ => chromium}/letTargetGo.ts | 4 +- .../driven/{ => chromium}/readActivePort.ts | 0 .../driven/{ => chromium}/readTabInfos.ts | 9 +- .../driven/{ => chromium}/releaseTab.ts | 0 .../driven/{ => chromium}/reloadTab.ts | 2 +- src/main/browsers/driven/chromium/types.ts | 26 ++++ src/main/browsers/driven/constants.ts | 27 +--- .../browsers/driven/firefox/DrivenFirefox.ts | 128 ++++++++++++++++ .../browsers/driven/firefox/captureContext.ts | 23 +++ .../driven/firefox/captureContextAt.ts | 23 +++ .../browsers/driven/firefox/connectFirefox.ts | 18 +++ src/main/browsers/driven/firefox/constants.ts | 38 +++++ .../browsers/driven/firefox/evaluateIn.ts | 14 ++ .../browsers/driven/firefox/launchFirefox.ts | 14 ++ .../browsers/driven/firefox/readBidiPort.ts | 14 ++ .../driven/firefox/readFirefoxTabs.ts | 16 ++ src/main/browsers/driven/firefox/types.ts | 6 + src/main/browsers/driven/launchDriven.ts | 26 ++++ src/main/browsers/driven/launchForDriving.ts | 28 ---- src/main/browsers/driven/reachOrLaunch.ts | 19 +-- src/main/browsers/driven/shotBrowser.ts | 6 +- src/main/browsers/driven/types.ts | 73 ++++++--- src/main/engine/answering/answerContext.ts | 8 + src/main/engine/answering/constants.ts | 5 + src/main/engine/answering/decideRequest.ts | 24 +++ src/main/engine/answering/index.ts | 4 + src/main/engine/answering/ruledHead.ts | 9 ++ src/main/engine/answering/servedHead.ts | 18 +++ src/main/engine/answering/types.ts | 21 +++ src/main/engine/bidi/BidiConnection.ts | 83 +++++++++++ src/main/engine/bidi/BidiInterception.ts | 70 +++++++++ src/main/engine/bidi/answerRequest.ts | 24 +++ src/main/engine/bidi/answerResponse.ts | 15 ++ src/main/engine/bidi/bidiHeaders.ts | 7 + src/main/engine/bidi/constants.ts | 64 ++++++++ src/main/engine/bidi/headerEntriesOf.ts | 7 + src/main/engine/bidi/index.ts | 3 + src/main/engine/bidi/provideResponse.ts | 16 ++ src/main/engine/bidi/requestOf.ts | 7 + src/main/engine/bidi/resourceTypeOf.ts | 8 + src/main/engine/bidi/types.ts | 30 ++++ src/renderer/src/entities/browser/index.ts | 1 + .../src/entities/browser/lib/canDrive.ts | 9 ++ .../src/entities/browser/lib/index.ts | 1 + .../browser/open-in-browser/ui/BrowserRow.tsx | 8 +- .../command-palette/ui/browserItems.ts | 7 +- src/shared/types/browsers.ts | 5 + src/shared/types/index.ts | 4 +- test/e2e/browsers.e2e.test.ts | 2 +- test/e2e/driven-firefox.e2e.test.ts | 119 +++++++++++++++ test/e2e/driven.e2e.test.ts | 2 +- test/helpers/killMatching.ts | 19 +++ test/integration/driven.chromium.test.ts | 8 +- test/integration/driven.firefox.test.ts | 138 ++++++++++++++++++ test/unit/bidi.test.ts | 95 ++++++++++++ test/unit/browsers.test.ts | 18 ++- 65 files changed, 1472 insertions(+), 228 deletions(-) create mode 100644 src/main/browsers/driven/DrivenBase.ts rename src/main/browsers/driven/{ => chromium}/DrivenChromium.ts (65%) rename src/main/browsers/driven/{ => chromium}/attachTab.ts (77%) rename src/main/browsers/driven/{ => chromium}/captureTabAt.ts (73%) create mode 100644 src/main/browsers/driven/chromium/connectChromium.ts create mode 100644 src/main/browsers/driven/chromium/constants.ts create mode 100644 src/main/browsers/driven/chromium/launchChromium.ts rename src/main/browsers/driven/{ => chromium}/letTargetGo.ts (74%) rename src/main/browsers/driven/{ => chromium}/readActivePort.ts (100%) rename src/main/browsers/driven/{ => chromium}/readTabInfos.ts (57%) rename src/main/browsers/driven/{ => chromium}/releaseTab.ts (100%) rename src/main/browsers/driven/{ => chromium}/reloadTab.ts (87%) create mode 100644 src/main/browsers/driven/chromium/types.ts create mode 100644 src/main/browsers/driven/firefox/DrivenFirefox.ts create mode 100644 src/main/browsers/driven/firefox/captureContext.ts create mode 100644 src/main/browsers/driven/firefox/captureContextAt.ts create mode 100644 src/main/browsers/driven/firefox/connectFirefox.ts create mode 100644 src/main/browsers/driven/firefox/constants.ts create mode 100644 src/main/browsers/driven/firefox/evaluateIn.ts create mode 100644 src/main/browsers/driven/firefox/launchFirefox.ts create mode 100644 src/main/browsers/driven/firefox/readBidiPort.ts create mode 100644 src/main/browsers/driven/firefox/readFirefoxTabs.ts create mode 100644 src/main/browsers/driven/firefox/types.ts create mode 100644 src/main/browsers/driven/launchDriven.ts delete mode 100644 src/main/browsers/driven/launchForDriving.ts create mode 100644 src/main/engine/answering/answerContext.ts create mode 100644 src/main/engine/answering/constants.ts create mode 100644 src/main/engine/answering/decideRequest.ts create mode 100644 src/main/engine/answering/index.ts create mode 100644 src/main/engine/answering/ruledHead.ts create mode 100644 src/main/engine/answering/servedHead.ts create mode 100644 src/main/engine/answering/types.ts create mode 100644 src/main/engine/bidi/BidiConnection.ts create mode 100644 src/main/engine/bidi/BidiInterception.ts create mode 100644 src/main/engine/bidi/answerRequest.ts create mode 100644 src/main/engine/bidi/answerResponse.ts create mode 100644 src/main/engine/bidi/bidiHeaders.ts create mode 100644 src/main/engine/bidi/constants.ts create mode 100644 src/main/engine/bidi/headerEntriesOf.ts create mode 100644 src/main/engine/bidi/index.ts create mode 100644 src/main/engine/bidi/provideResponse.ts create mode 100644 src/main/engine/bidi/requestOf.ts create mode 100644 src/main/engine/bidi/resourceTypeOf.ts create mode 100644 src/main/engine/bidi/types.ts create mode 100644 src/renderer/src/entities/browser/lib/canDrive.ts create mode 100644 src/renderer/src/entities/browser/lib/index.ts create mode 100644 test/e2e/driven-firefox.e2e.test.ts create mode 100644 test/helpers/killMatching.ts create mode 100644 test/integration/driven.firefox.test.ts create mode 100644 test/unit/bidi.test.ts diff --git a/src/main/browsers/driven/DrivenBase.ts b/src/main/browsers/driven/DrivenBase.ts new file mode 100644 index 0000000..d4b5599 --- /dev/null +++ b/src/main/browsers/driven/DrivenBase.ts @@ -0,0 +1,50 @@ +import type { CaptureArea, DrivenBrowser } from '../../../shared/types'; +import type { CapturedImage } from '../../shots/capture'; +import type { FoundBrowser } from '../types'; +import { DrivenTabs } from './DrivenTabs'; +import type { DriverDeps, KeptTab, TabCapture, TabRead } from './types'; + +/** What every driven browser does the same way, whatever protocol it speaks: its tabs, listed, read again and captured. */ +export abstract class DrivenBase { + protected readonly tabs = new DrivenTabs(); + protected readonly disposers: Array<() => void> = []; + + constructor( + readonly browser: FoundBrowser, + private readonly version: string | null, + protected readonly deps: DriverDeps, + ) {} + + list(): DrivenBrowser { + return this.tabs.described(this.browser, this.version); + } + + /** + * Reads each tab's title and address again: a title a page sets isn't announced (only its address is), so it is + * read once the page has loaded, and whenever the tabs are listed. + */ + async readTabs(ids = this.tabs.list().map((t) => t.id)): Promise { + if (this.tabs.updateAll(await this.read(ids))) this.deps.changed(); + } + + /** Captures a tab (brought to the front first: a hidden tab isn't drawn); with the address it showed. */ + async capture(tabId: string, area: Exclude): Promise { + const tab = this.tabs.get(tabId); + await this.activate(tabId); + return { image: await this.take(tab, area), url: tab.info.url }; + } + + /** Brings a tab to the front, in its window. */ + abstract activate(tabId: string): Promise; + + /** What the browser says of each tab now. */ + protected abstract read(ids: string[]): Promise; + + /** Captures a tab that is in front. */ + protected abstract take(tab: T, area: Exclude): Promise; + + /** Lets go of what was listened to. */ + protected dispose(): void { + for (const dispose of this.disposers.splice(0)) dispose(); + } +} diff --git a/src/main/browsers/driven/DrivenBrowsers.ts b/src/main/browsers/driven/DrivenBrowsers.ts index 25f2447..bd9ad77 100644 --- a/src/main/browsers/driven/DrivenBrowsers.ts +++ b/src/main/browsers/driven/DrivenBrowsers.ts @@ -1,27 +1,28 @@ -import type { AppEvent, CaptureArea, DrivenBrowser, ShotBrowser } from '../../../shared/types'; +import type { AppEvent, CaptureArea, DrivenBrowser, DrivenEngine, ShotBrowser } from '../../../shared/types'; import { HTTP_URL } from '../../constants'; -import { CDP } from '../../engine/constants'; import type { BrowserCapture, CapturedImage, Viewport } from '../../shots/capture'; import { PAGE_AREAS } from '../../shots/constants'; import type { FoundBrowser } from '../types'; -import { PRODUCT_VERSION, RELOAD_DEBOUNCE_MS } from './constants'; -import { DrivenChromium } from './DrivenChromium'; -import { profileDir } from './profileDir'; -import { reachOrLaunch } from './reachOrLaunch'; +import { connectChromium } from './chromium/connectChromium'; +import { RELOAD_DEBOUNCE_MS } from './constants'; +import { connectFirefox } from './firefox/connectFirefox'; import { shotBrowser } from './shotBrowser'; -import type { DrivenBrowsersDeps } from './types'; +import type { ConnectDriver, DrivenBrowsersDeps, Driver } from './types'; + +/** How a browser of each engine is driven (the UI offers what `DRIVEN_ENGINES` lists); another can't be served the workspace's changes. */ +const DRIVERS: Readonly> = { chromium: connectChromium, gecko: connectFirefox }; /** What each app event means for the driven browsers; the rest mean nothing to them. */ type AppEventReactions = Partial void>>; /** - * The Chromium browsers the app drives, one per installed browser, each with a profile of the app's own: launched (or - * reached again) when an address is first opened in one with the workspace's changes, and kept in step with them. - * Every change is announced as `driven-browsers-changed`. + * The browsers the app drives (Chromium ones and Firefox), one per installed browser, each with a profile of the app's + * own: launched (or reached again) when an address is first opened in one with the workspace's changes, and kept in + * step with them. Every change is announced as `driven-browsers-changed`. */ export class DrivenBrowsers { - private readonly driven = new Map(); - private readonly starting = new Map>(); + private readonly driven = new Map(); + private readonly starting = new Map>(); private reloadTimer: ReturnType | undefined; private readonly reactions: AppEventReactions = { 'overrides-changed': () => this.served(), @@ -45,8 +46,9 @@ export class DrivenBrowsers { async open(id: string, url: string): Promise { if (!HTTP_URL.test(url)) throw new Error('Only http(s) pages open in another browser'); const browser = await this.deps.registry.get(id); - if (browser.engine !== 'chromium') throw new Error(`${browser.name} can't be served your changes: only Chromium browsers can, for now`); - await (await this.reach(browser)).open(url); + const connect = Object.hasOwn(DRIVERS, browser.engine) ? DRIVERS[browser.engine as DrivenEngine] : undefined; + if (!connect) throw new Error(`${browser.name} can't be served your changes: only Chromium browsers and Firefox can`); + await (await this.reach(browser, connect)).open(url); } activate(browserId: string, tabId: string): Promise { @@ -91,38 +93,32 @@ export class DrivenBrowsers { this.driven.clear(); } - private all(): DrivenChromium[] { + private all(): Driver[] { return [...this.driven.values()]; } - private get(id: string): DrivenChromium { + private get(id: string): Driver { const driven = this.driven.get(id); if (!driven) throw new Error("That browser isn't open with your changes any more"); return driven; } /** The driven browser for an installed one: already connected, connecting, or launched now. */ - private reach(browser: FoundBrowser): Promise { + private reach(browser: FoundBrowser, connect: ConnectDriver): Promise { const driven = this.driven.get(browser.id); if (driven) return Promise.resolve(driven); - const starting = this.starting.get(browser.id) ?? this.connect(browser).finally(() => this.starting.delete(browser.id)); + const starting = this.starting.get(browser.id) ?? this.connect(browser, connect).finally(() => this.starting.delete(browser.id)); this.starting.set(browser.id, starting); return starting; } - private async connect(browser: FoundBrowser): Promise { - const connection = await reachOrLaunch(browser, profileDir(browser, this.deps.userData)); - const { product } = await connection.send<{ product: string }>(CDP.Browser.getVersion); - const driven = new DrivenChromium(browser, PRODUCT_VERSION.exec(product)?.[1] ?? null, connection, { - sources: this.deps.sources, - changed: () => this.changed(), - closed: () => { - this.driven.delete(browser.id); - this.changed(); - }, - }); + private async connect(browser: FoundBrowser, connect: ConnectDriver): Promise { + const closed = () => { + this.driven.delete(browser.id); + this.changed(); + }; + const driven = await connect(browser, { sources: this.deps.sources, userData: this.deps.userData, changed: () => this.changed(), closed }); this.driven.set(browser.id, driven); - await driven.start(); this.changed(); return driven; } diff --git a/src/main/browsers/driven/DrivenTabs.ts b/src/main/browsers/driven/DrivenTabs.ts index 10b34b8..fd0e2f4 100644 --- a/src/main/browsers/driven/DrivenTabs.ts +++ b/src/main/browsers/driven/DrivenTabs.ts @@ -1,56 +1,73 @@ -import type { DrivenTab } from '../../../shared/types'; +import type { DrivenBrowser, DrivenTab } from '../../../shared/types'; +import { HTTP_URL } from '../../constants'; +import type { FoundBrowser } from '../types'; import { START_URL } from './constants'; -import type { DrivenTabState, PageTargetInfo } from './types'; +import type { KeptTab, TabRead } from './types'; -/** A driven browser's tabs, in the order they were attached; waits for the tab a new target becomes. */ -export class DrivenTabs { - private readonly tabs = new Map(); - private readonly arrivals = new Map void>(); +/** A driven browser's tabs, in the order they were found; waits for the tab a new one becomes. */ +export class DrivenTabs { + private readonly tabs = new Map(); + private readonly arrivals = new Map void>(); - add(tab: DrivenTabState): void { + add(tab: T): void { this.tabs.set(tab.info.id, tab); this.arrivals.get(tab.info.id)?.(tab); this.arrivals.delete(tab.info.id); } - /** Forgets the tab whose session went, and returns it. */ - remove(sessionId: string): DrivenTabState | undefined { - const tab = this.all().find((t) => t.sessionId === sessionId); - if (tab) this.tabs.delete(tab.info.id); + has(id: string): boolean { + return this.tabs.has(id); + } + + /** Forgets a tab (it closed), and returns it. */ + remove(id: string): T | undefined { + const tab = this.tabs.get(id); + this.tabs.delete(id); return tab; } /** Takes a tab's new address or title; false when it isn't a tab here, or neither changed. */ - update({ targetId, url, title }: PageTargetInfo): boolean { - const tab = this.tabs.get(targetId); - if (!tab || (tab.info.url === url && tab.info.title === title)) return false; - tab.info = { ...tab.info, url, title }; + update(id: string, { url, title }: Partial>): boolean { + const tab = this.tabs.get(id); + const next = tab && { ...tab.info, ...(url === undefined ? {} : { url }), ...(title === undefined ? {} : { title }) }; + if (!tab || !next || (next.url === tab.info.url && next.title === tab.info.title)) return false; + tab.info = next; return true; } - get(id: string): DrivenTabState { + /** Takes what was read of each tab again; whether anything changed. */ + updateAll(reads: readonly TabRead[]): boolean { + return reads.filter(({ id, ...change }) => this.update(id, change)).length > 0; + } + + get(id: string): T { const tab = this.tabs.get(id); if (!tab) throw new Error('That tab is closed'); return tab; } - /** The tab of a target just created, once it is attached. */ - arrival(targetId: string): Promise { - const tab = this.tabs.get(targetId); - return tab ? Promise.resolve(tab) : new Promise((resolve) => this.arrivals.set(targetId, resolve)); + /** The tab of a target just created, once it is found. */ + arrival(id: string): Promise { + const tab = this.tabs.get(id); + return tab ? Promise.resolve(tab) : new Promise((resolve) => this.arrivals.set(id, resolve)); } /** The tab showing an address, if one does. */ - showing(url: string): DrivenTabState | undefined { + showing(url: string): T | undefined { return this.all().find((t) => t.info.url === url); } /** A blank tab (the one the browser started on) to load an address in, rather than opening another. */ - blank(): DrivenTabState | undefined { + blank(): T | undefined { return this.all().find((t) => t.info.url === START_URL); } - all(): DrivenTabState[] { + /** The tabs showing a website (not a blank or browser page). */ + webPages(): T[] { + return this.all().filter((t) => HTTP_URL.test(t.info.url)); + } + + all(): T[] { return [...this.tabs.values()]; } @@ -58,8 +75,13 @@ export class DrivenTabs { return this.all().map((t) => t.info); } + /** The browser with these tabs, as listed. */ + described({ id, name }: Pick, version: string | null): DrivenBrowser { + return { id, name, version, tabs: this.list() }; + } + /** Forgets every tab, and returns them. */ - clear(): DrivenTabState[] { + clear(): T[] { const all = this.all(); this.tabs.clear(); this.arrivals.clear(); diff --git a/src/main/browsers/driven/DrivenChromium.ts b/src/main/browsers/driven/chromium/DrivenChromium.ts similarity index 65% rename from src/main/browsers/driven/DrivenChromium.ts rename to src/main/browsers/driven/chromium/DrivenChromium.ts index 3f9be04..77cd3d7 100644 --- a/src/main/browsers/driven/DrivenChromium.ts +++ b/src/main/browsers/driven/chromium/DrivenChromium.ts @@ -1,42 +1,42 @@ -import type { CaptureArea, DrivenBrowser } from '../../../shared/types'; -import { HTTP_URL } from '../../constants'; -import { CDP } from '../../engine/constants'; -import { withTimeout } from '../../engine/PageInterception'; -import type { CdpConnection } from '../../engine/websocketTransport'; -import { captureOverCdp, type CapturedImage, type Viewport } from '../../shots/capture'; -import type { FoundBrowser } from '../types'; +import type { CaptureArea } from '../../../../shared/types'; +import { CDP } from '../../../engine/constants'; +import { withTimeout } from '../../../engine/PageInterception'; +import type { CdpConnection } from '../../../engine/websocketTransport'; +import { captureOverCdp, type CapturedImage, type Viewport } from '../../../shots/capture'; +import type { FoundBrowser } from '../../types'; import { attachTab } from './attachTab'; import { captureTabAt } from './captureTabAt'; -import { NEW_TAB_TIMEOUT_MS, PAGE_ATTACH, PAGE_TARGET, START_URL, STOP_ATTACH } from './constants'; -import { DrivenTabs } from './DrivenTabs'; +import { NEW_TAB_TIMEOUT_MS, START_URL } from '../constants'; +import { DrivenBase } from '../DrivenBase'; +import type { Driver, DriverDeps, TabCapture, TabRead } from '../types'; +import { PAGE_ATTACH, PAGE_TARGET, STOP_ATTACH } from './constants'; import { letTargetGo } from './letTargetGo'; import { readTabInfos } from './readTabInfos'; import { releaseTab } from './releaseTab'; import { reloadTab } from './reloadTab'; -import type { AttachedPage, DrivenChromiumDeps, DrivenTabState, PageTargetInfo } from './types'; +import type { AttachedPage, DrivenTabState, PageTargetInfo } from './types'; /** * A Chromium browser the app launched, over its browser-level CDP connection: every tab is attached (a new one before * it loads anything) and served the workspace's overrides and rules by an interception of its own, the same engine as * the app's page. Tabs are listed with their address and title as they change. */ -export class DrivenChromium { - private readonly tabs = new DrivenTabs(); - private readonly disposers: Array<() => void> = []; - +export class DrivenChromium extends DrivenBase implements Driver { constructor( - readonly browser: FoundBrowser, - private readonly version: string | null, + browser: FoundBrowser, + version: string | null, private readonly connection: CdpConnection, - private readonly deps: DrivenChromiumDeps, - ) {} + deps: DriverDeps, + ) { + super(browser, version, deps); + } /** Attaches to the browser's tabs, the ones open now and every one opened later. */ async start(): Promise { const events: Partial void>> = { [CDP.Target.attachedToTarget]: (p: AttachedPage) => this.attached(p), [CDP.Target.detachedFromTarget]: (p: { sessionId: string }) => this.detached(p.sessionId), - [CDP.Target.targetInfoChanged]: (p: { targetInfo: PageTargetInfo }) => this.tabs.update(p.targetInfo) && this.deps.changed(), + [CDP.Target.targetInfoChanged]: (p: { targetInfo: PageTargetInfo }) => this.tabs.update(p.targetInfo.targetId, p.targetInfo) && this.deps.changed(), }; this.disposers.push( // The browser's own events carry no session; its tabs' go to their interceptions. @@ -49,19 +49,6 @@ export class DrivenChromium { await this.connection.send(CDP.Target.setAutoAttach, { ...PAGE_ATTACH }); } - list(): DrivenBrowser { - return { id: this.browser.id, name: this.browser.name, version: this.version, tabs: this.tabs.list() }; - } - - /** - * Reads each tab's title and address again: a title a page sets isn't announced (only its address is), so it is - * read once the page has loaded, and whenever the tabs are listed. - */ - async readTabs(ids = this.tabs.list().map((t) => t.id)): Promise { - const infos = await readTabInfos(this.connection, ids); - if (infos.filter((info) => this.tabs.update(info)).length) this.deps.changed(); - } - /** Opens an address in the blank tab the browser started on, or else in a new tab, and brings it to the front. */ async open(url: string): Promise { const tab = this.tabs.blank() ?? (await this.newTab()); @@ -74,26 +61,18 @@ export class DrivenChromium { } /** Captures the whole page at `url`, in the tab showing it (or one opened there), laid out in `viewport`. */ - async captureAt(url: string, viewport: Viewport): Promise<{ image: CapturedImage; url: string }> { + async captureAt(url: string, viewport: Viewport): Promise { const shown = this.tabs.showing(url); const tab = shown ?? (await this.open(url)); if (shown) await this.activate(tab.info.id); return captureTabAt(tab, viewport); } - /** Brings a tab to the front, in its window. */ async activate(tabId: string): Promise { this.tabs.get(tabId); await this.connection.send(CDP.Target.activateTarget, { targetId: tabId }); } - /** Captures a tab (brought to the front first: a hidden tab isn't drawn); with the address it showed. */ - async capture(tabId: string, area: Exclude): Promise<{ image: CapturedImage; url: string }> { - const tab = this.tabs.get(tabId); - await this.activate(tabId); - return { image: await captureOverCdp(tab.transport, { area }), url: tab.info.url }; - } - /** After overrides or rules changed: what each tab intercepts. */ async refresh(): Promise { await Promise.all(this.tabs.all().map((t) => t.interception.refreshInterception().catch(() => undefined))); @@ -106,13 +85,12 @@ export class DrivenChromium { /** Reloads the tabs showing a website, so what changed is served. */ async reload(): Promise { - const pages = this.tabs.all().filter((t) => HTTP_URL.test(t.info.url)); - await Promise.all(pages.map((t) => reloadTab(t).catch(() => undefined))); + await Promise.all(this.tabs.webPages().map((t) => reloadTab(t).catch(() => undefined))); } /** Stops serving the workspace's changes: the browser stays open, as it is. */ stop(): void { - for (const dispose of this.disposers.splice(0)) dispose(); + this.dispose(); for (const tab of this.tabs.clear()) releaseTab(tab); this.connection.send(CDP.Target.setAutoAttach, { ...STOP_ATTACH }).catch(() => undefined); this.connection.close(); @@ -128,12 +106,21 @@ export class DrivenChromium { } private detached(sessionId: string): void { - const tab = this.tabs.remove(sessionId); + const tab = this.tabs.all().find((t) => t.sessionId === sessionId); if (!tab) return; + this.tabs.remove(tab.info.id); releaseTab(tab); this.deps.changed(); } + protected read(ids: string[]): Promise { + return readTabInfos(this.connection, ids); + } + + protected take(tab: DrivenTabState, area: Exclude): Promise { + return captureOverCdp(tab.transport, { area }); + } + private async newTab(): Promise { const { targetId } = await this.connection.send<{ targetId: string }>(CDP.Target.createTarget, { url: START_URL }); return withTimeout(this.tabs.arrival(targetId), NEW_TAB_TIMEOUT_MS, 'Opening a tab'); @@ -141,7 +128,7 @@ export class DrivenChromium { /** The browser was quit: its tabs are gone with it. */ private closed(): void { - for (const dispose of this.disposers.splice(0)) dispose(); + this.dispose(); for (const tab of this.tabs.clear()) tab.interception.detach(); this.deps.closed(); } diff --git a/src/main/browsers/driven/attachTab.ts b/src/main/browsers/driven/chromium/attachTab.ts similarity index 77% rename from src/main/browsers/driven/attachTab.ts rename to src/main/browsers/driven/chromium/attachTab.ts index ddf126a..4b5c9cd 100644 --- a/src/main/browsers/driven/attachTab.ts +++ b/src/main/browsers/driven/chromium/attachTab.ts @@ -1,7 +1,8 @@ -import { CDP } from '../../engine/constants'; -import { PageInterception } from '../../engine/PageInterception'; -import { pageTransport, type CdpConnection } from '../../engine/websocketTransport'; -import type { AttachedPage, DrivenTabState, InterceptionSources } from './types'; +import { CDP } from '../../../engine/constants'; +import { PageInterception } from '../../../engine/PageInterception'; +import { pageTransport, type CdpConnection } from '../../../engine/websocketTransport'; +import type { InterceptionSources } from '../types'; +import type { AttachedPage, DrivenTabState } from './types'; /** * Serves the workspace's changes in a tab just attached: an interception of its own on its session, set up before diff --git a/src/main/browsers/driven/captureTabAt.ts b/src/main/browsers/driven/chromium/captureTabAt.ts similarity index 73% rename from src/main/browsers/driven/captureTabAt.ts rename to src/main/browsers/driven/chromium/captureTabAt.ts index e02d3ad..4f9ebc1 100644 --- a/src/main/browsers/driven/captureTabAt.ts +++ b/src/main/browsers/driven/chromium/captureTabAt.ts @@ -1,8 +1,8 @@ -import { CDP } from '../../engine/constants'; -import { withTimeout } from '../../engine/PageInterception'; -import { atViewport, captureOverCdp, type CapturedImage, type Viewport } from '../../shots/capture'; -import { LOADED_EXPRESSION } from '../../shots/constants'; -import { LOAD_TIMEOUT_MS } from './constants'; +import { CDP } from '../../../engine/constants'; +import { withTimeout } from '../../../engine/PageInterception'; +import { atViewport, captureOverCdp, type CapturedImage, type Viewport } from '../../../shots/capture'; +import { LOADED_EXPRESSION } from '../../../shots/constants'; +import { LOAD_TIMEOUT_MS } from '../constants'; import type { DrivenTabState } from './types'; /** Captures the whole page a tab shows, laid out in `viewport`, once it has loaded and been quiet a moment. */ diff --git a/src/main/browsers/driven/chromium/connectChromium.ts b/src/main/browsers/driven/chromium/connectChromium.ts new file mode 100644 index 0000000..bf41e24 --- /dev/null +++ b/src/main/browsers/driven/chromium/connectChromium.ts @@ -0,0 +1,20 @@ +import { CDP } from '../../../engine/constants'; +import { CdpConnection } from '../../../engine/websocketTransport'; +import type { FoundBrowser } from '../../types'; +import { profileDir } from '../profileDir'; +import { reachOrLaunch } from '../reachOrLaunch'; +import type { Driver, DriverDeps } from '../types'; +import { PRODUCT_VERSION } from './constants'; +import { DrivenChromium } from './DrivenChromium'; +import { launchChromium } from './launchChromium'; +import { readActivePort } from './readActivePort'; + +/** Drives a Chromium browser over CDP: the one still open with the app's profile for it, or one launched now. */ +export async function connectChromium(browser: FoundBrowser, deps: DriverDeps): Promise { + const dir = profileDir(browser, deps.userData); + const connection = await reachOrLaunch(() => readActivePort(dir), (address) => CdpConnection.connect(address), () => launchChromium(browser, dir)); + const { product } = await connection.send<{ product: string }>(CDP.Browser.getVersion); + const driver = new DrivenChromium(browser, PRODUCT_VERSION.exec(product)?.[1] ?? null, connection, deps); + await driver.start(); + return driver; +} diff --git a/src/main/browsers/driven/chromium/constants.ts b/src/main/browsers/driven/chromium/constants.ts new file mode 100644 index 0000000..99fb43c --- /dev/null +++ b/src/main/browsers/driven/chromium/constants.ts @@ -0,0 +1,22 @@ +/** The file Chromium writes in its profile when started with a debugging port: the port, then the browser's path. */ +export const ACTIVE_PORT_FILE = 'DevToolsActivePort'; + +/** What a browser started to be driven is given: a debugging port the system picks, and no first-run pages. */ +export const DRIVE_FLAGS = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check'] as const; +export const USER_DATA_FLAG = '--user-data-dir='; +export const DISABLE_FEATURES_FLAG = '--disable-features='; + +/** The target type of a tab. */ +export const PAGE_TARGET = 'page'; + +/** + * Every tab of a driven browser is attached, and one opened later waits (before it loads anything) until its + * interception is set up. Its frames and workers are attached below it, by its own interception. + */ +export const PAGE_ATTACH = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, filter: [{ type: PAGE_TARGET }, { exclude: true }] } as const; + +/** Letting go of a driven browser's tabs: new ones start as they would. */ +export const STOP_ATTACH = { autoAttach: false, waitForDebuggerOnStart: false, flatten: true } as const; + +/** `Browser.getVersion`'s product: `Chrome/140.0.7339.80`, the version after the slash. */ +export const PRODUCT_VERSION = /\/(\S+)$/; diff --git a/src/main/browsers/driven/chromium/launchChromium.ts b/src/main/browsers/driven/chromium/launchChromium.ts new file mode 100644 index 0000000..deb150f --- /dev/null +++ b/src/main/browsers/driven/chromium/launchChromium.ts @@ -0,0 +1,11 @@ +import { LOCAL_NETWORK_ACCESS_FEATURES } from '../../../chromiumFlags'; +import type { FoundBrowser } from '../../types'; +import { launchDriven } from '../launchDriven'; +import { ACTIVE_PORT_FILE, DISABLE_FEATURES_FLAG, DRIVE_FLAGS, USER_DATA_FLAG } from './constants'; +import { readActivePort } from './readActivePort'; + +/** Starts a Chromium browser to be driven: its profile in `dir`, a debugging port, the app's Chromium switches. */ +export function launchChromium(browser: FoundBrowser, dir: string): Promise { + const flags = [`${USER_DATA_FLAG}${dir}`, ...DRIVE_FLAGS, `${DISABLE_FEATURES_FLAG}${LOCAL_NETWORK_ACCESS_FEATURES.join(',')}`]; + return launchDriven(browser, dir, { flags, portFile: ACTIVE_PORT_FILE, read: readActivePort }); +} diff --git a/src/main/browsers/driven/letTargetGo.ts b/src/main/browsers/driven/chromium/letTargetGo.ts similarity index 74% rename from src/main/browsers/driven/letTargetGo.ts rename to src/main/browsers/driven/chromium/letTargetGo.ts index be5da6c..2a4c7e0 100644 --- a/src/main/browsers/driven/letTargetGo.ts +++ b/src/main/browsers/driven/chromium/letTargetGo.ts @@ -1,5 +1,5 @@ -import { CDP } from '../../engine/constants'; -import type { CdpConnection } from '../../engine/websocketTransport'; +import { CDP } from '../../../engine/constants'; +import type { CdpConnection } from '../../../engine/websocketTransport'; /** Lets a target that isn't a tab go on as it came: run, and no longer attached. */ export function letTargetGo(connection: CdpConnection, sessionId: string): void { diff --git a/src/main/browsers/driven/readActivePort.ts b/src/main/browsers/driven/chromium/readActivePort.ts similarity index 100% rename from src/main/browsers/driven/readActivePort.ts rename to src/main/browsers/driven/chromium/readActivePort.ts diff --git a/src/main/browsers/driven/readTabInfos.ts b/src/main/browsers/driven/chromium/readTabInfos.ts similarity index 57% rename from src/main/browsers/driven/readTabInfos.ts rename to src/main/browsers/driven/chromium/readTabInfos.ts index 769b930..4414103 100644 --- a/src/main/browsers/driven/readTabInfos.ts +++ b/src/main/browsers/driven/chromium/readTabInfos.ts @@ -1,9 +1,10 @@ -import { CDP } from '../../engine/constants'; -import type { CdpConnection } from '../../engine/websocketTransport'; +import { CDP } from '../../../engine/constants'; +import type { CdpConnection } from '../../../engine/websocketTransport'; +import type { TabRead } from '../types'; import type { PageTargetInfo } from './types'; /** What the browser says of each tab now (its title, which isn't announced as it changes); a tab gone is left out. */ -export async function readTabInfos(connection: CdpConnection, ids: string[]): Promise { +export async function readTabInfos(connection: CdpConnection, ids: string[]): Promise { const infos = await Promise.all(ids.map((targetId) => connection.send<{ targetInfo: PageTargetInfo }>(CDP.Target.getTargetInfo, { targetId }).then(({ targetInfo }) => targetInfo, () => null))); - return infos.filter((info) => info !== null); + return infos.filter((info) => info !== null).map(({ targetId, url, title }) => ({ id: targetId, url, title })); } diff --git a/src/main/browsers/driven/releaseTab.ts b/src/main/browsers/driven/chromium/releaseTab.ts similarity index 100% rename from src/main/browsers/driven/releaseTab.ts rename to src/main/browsers/driven/chromium/releaseTab.ts diff --git a/src/main/browsers/driven/reloadTab.ts b/src/main/browsers/driven/chromium/reloadTab.ts similarity index 87% rename from src/main/browsers/driven/reloadTab.ts rename to src/main/browsers/driven/chromium/reloadTab.ts index fd3d0c7..1a0c7ae 100644 --- a/src/main/browsers/driven/reloadTab.ts +++ b/src/main/browsers/driven/chromium/reloadTab.ts @@ -1,4 +1,4 @@ -import { CDP } from '../../engine/constants'; +import { CDP } from '../../../engine/constants'; import type { DrivenTabState } from './types'; /** Reloads a tab from the network, first asking service workers that run outdated code to unregister. */ diff --git a/src/main/browsers/driven/chromium/types.ts b/src/main/browsers/driven/chromium/types.ts new file mode 100644 index 0000000..137a8de --- /dev/null +++ b/src/main/browsers/driven/chromium/types.ts @@ -0,0 +1,26 @@ +import type { DrivenTab } from '../../../../shared/types'; +import type { PageInterception } from '../../../engine/PageInterception'; +import type { PageTransport } from '../../../engine/websocketTransport'; + +/** A tab of a driven Chromium browser, with the interception serving it. */ +export interface DrivenTabState { + info: DrivenTab; + sessionId: string; + transport: PageTransport; + interception: PageInterception; + /** Settles once its interception is set up (or couldn't be) and the tab runs. */ + ready: Promise; +} + +/** What `Target.attachedToTarget` and `Target.targetInfoChanged` say of a target, as far as a tab needs. */ +export interface PageTargetInfo { + targetId: string; + type: string; + url: string; + title: string; +} + +export interface AttachedPage { + sessionId: string; + targetInfo: PageTargetInfo; +} diff --git a/src/main/browsers/driven/constants.ts b/src/main/browsers/driven/constants.ts index 61bb8b3..c5524ce 100644 --- a/src/main/browsers/driven/constants.ts +++ b/src/main/browsers/driven/constants.ts @@ -7,16 +7,8 @@ export const SANDBOXED_PROFILE = 'console-editor-profile'; /** A Snap's own writable folder: `~/snap//common`; a Flatpak's: `~/.var/app//data`. */ export const SANDBOX_DIRS = { snapBin: '/snap/bin/', snap: 'snap', snapCommon: 'common', flatpak: 'flatpak', flatpakRun: 'run', flatpakData: '.var/app', flatpakDataSub: 'data' } as const; -/** The file Chromium writes in its profile when started with a debugging port: the port, then the browser's path. */ -export const ACTIVE_PORT_FILE = 'DevToolsActivePort'; - -/** Waiting for a launched browser to write it. */ -export const ACTIVE_PORT_WAIT = { timeoutMs: 30_000, stepMs: 150 } as const; - -/** What a browser started to be driven is given: a debugging port the system picks, and no first-run pages. */ -export const DRIVE_FLAGS = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check'] as const; -export const USER_DATA_FLAG = '--user-data-dir='; -export const DISABLE_FEATURES_FLAG = '--disable-features='; +/** Waiting for a launched browser to write the address of its debugging port in its profile. */ +export const PORT_WAIT = { timeoutMs: 30_000, stepMs: 150 } as const; /** The page a driven browser starts on: nothing loads before the app has attached to it. */ export const START_URL = 'about:blank'; @@ -27,26 +19,11 @@ export const MAC_OPEN = { newInstance: '-n', args: '--args' } as const; /** Flatpak's markers around forwarded files: flags go before them. */ export const FLATPAK_MARKERS: readonly string[] = ['@@u', '@@']; -/** The target type of a tab. */ -export const PAGE_TARGET = 'page'; - -/** - * Every tab of a driven browser is attached, and one opened later waits (before it loads anything) until its - * interception is set up. Its frames and workers are attached below it, by its own interception. - */ -export const PAGE_ATTACH = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, filter: [{ type: PAGE_TARGET }, { exclude: true }] } as const; - -/** Letting go of a driven browser's tabs: new ones start as they would. */ -export const STOP_ATTACH = { autoAttach: false, waitForDebuggerOnStart: false, flatten: true } as const; - /** How long a tab captured in every browser may take to load before it is captured as it is. */ export const LOAD_TIMEOUT_MS = 30_000; /** How long a tab the app opens has to be attached before opening it fails. */ export const NEW_TAB_TIMEOUT_MS = 10_000; -/** `Browser.getVersion`'s product: `Chrome/140.0.7339.80`, the version after the slash. */ -export const PRODUCT_VERSION = /\/(\S+)$/; - /** Waiting this long after overrides or rules change before reloading driven tabs (a burst reloads them once). */ export const RELOAD_DEBOUNCE_MS = 300; diff --git a/src/main/browsers/driven/firefox/DrivenFirefox.ts b/src/main/browsers/driven/firefox/DrivenFirefox.ts new file mode 100644 index 0000000..533ee93 --- /dev/null +++ b/src/main/browsers/driven/firefox/DrivenFirefox.ts @@ -0,0 +1,128 @@ +import type { CaptureArea } from '../../../../shared/types'; +import { BIDI, BidiInterception, type BidiConnection } from '../../../engine/bidi'; +import { withTimeout } from '../../../engine/PageInterception'; +import type { CapturedImage, Viewport } from '../../../shots/capture'; +import type { FoundBrowser } from '../../types'; +import { LOAD_TIMEOUT_MS, START_URL } from '../constants'; +import { DrivenBase } from '../DrivenBase'; +import type { Driver, DriverDeps, KeptTab, TabCapture, TabRead } from '../types'; +import { captureContext } from './captureContext'; +import { captureContextAt } from './captureContextAt'; +import { NAVIGATE_WAIT, TAB_TYPE } from './constants'; +import { readFirefoxTabs } from './readFirefoxTabs'; +import type { ContextInfo } from './types'; + +/** + * Firefox, launched by the app, over its WebDriver BiDi session: the workspace's overrides and rules served in every + * tab by one interception (see {@link BidiInterception}), and its tabs (top-level browsing contexts) listed with their + * address and title as they change. + */ +export class DrivenFirefox extends DrivenBase implements Driver { + private readonly interception: BidiInterception; + + constructor( + browser: FoundBrowser, + version: string | null, + private readonly connection: BidiConnection, + deps: DriverDeps, + ) { + super(browser, version, deps); + const { store, rules, settings } = deps.sources; + this.interception = new BidiInterception(connection, { getOverrides: () => store.list(), getRules: () => rules.list(), getSettings: () => settings.get() }); + } + + async start(): Promise { + const moved = (p: ContextInfo) => this.tabs.update(p.context, { url: p.url }) && this.deps.changed(); + const events: Partial void>> = { + [BIDI.browsingContext.contextCreated]: (p: ContextInfo) => !p.parent && this.found(p.context, p.url), + [BIDI.browsingContext.contextDestroyed]: (p: ContextInfo) => !p.parent && this.tabs.remove(p.context) && this.deps.changed(), + [BIDI.browsingContext.navigationStarted]: moved, + [BIDI.browsingContext.fragmentNavigated]: moved, + [BIDI.browsingContext.historyUpdated]: moved, + // A title a page sets isn't reported: it is read once the page has loaded. + [BIDI.browsingContext.load]: (p: ContextInfo) => void this.readTabs([p.context]), + }; + this.disposers.push( + this.connection.onEvent((method, params) => events[method]?.(params)), + this.connection.onClose(() => this.closed()), + ); + await this.connection.send(BIDI.session.subscribe, { events: Object.keys(events) }); + await this.interception.start(); + const { contexts } = await this.connection.send<{ contexts: ContextInfo[] }>(BIDI.browsingContext.getTree, { maxDepth: 0 }); + for (const context of contexts) this.found(context.context, context.url); + await this.readTabs(); + } + + open(url: string): Promise { + return this.load(url, NAVIGATE_WAIT.none); + } + + async activate(tabId: string): Promise { + this.tabs.get(tabId); + await this.connection.send(BIDI.browsingContext.activate, { context: tabId }); + } + + async captureAt(url: string, viewport: Viewport): Promise { + const shown = this.tabs.showing(url); + const tab = shown ?? (await withTimeout(this.load(url, NAVIGATE_WAIT.loaded), LOAD_TIMEOUT_MS, 'Loading the page')); + if (shown) await this.activate(tab.info.id); + return { image: await captureContextAt(this.connection, tab.info.id, viewport), url: tab.info.url }; + } + + refresh(): Promise { + return this.interception.refresh(); + } + + applySettings(): Promise { + return this.interception.applySettings(); + } + + async reload(): Promise { + await Promise.all(this.tabs.webPages().map((t) => this.connection.send(BIDI.browsingContext.reload, { context: t.info.id, wait: NAVIGATE_WAIT.none }).catch(() => undefined))); + } + + stop(): void { + this.dispose(); + this.interception.stop(); + this.tabs.clear(); + this.connection.send(BIDI.session.end).catch(() => undefined); + this.connection.close(); + } + + protected read(ids: string[]): Promise { + return readFirefoxTabs(this.connection, ids); + } + + protected take(tab: KeptTab, area: Exclude): Promise { + return captureContext(this.connection, tab.info.id, area); + } + + /** Loads an address in the blank tab the browser started on, or else in a new tab, and brings it to the front. */ + private async load(url: string, wait: string): Promise { + const tab = this.tabs.blank() ?? (await this.newTab()); + // Taken: another address opened before this one shows isn't loaded in it too. + tab.info = { ...tab.info, url }; + await this.connection.send(BIDI.browsingContext.navigate, { context: tab.info.id, url, wait }); + await this.activate(tab.info.id); + return tab; + } + + private async newTab(): Promise { + const { context } = await this.connection.send<{ context: string }>(BIDI.browsingContext.create, { type: TAB_TYPE }); + this.found(context, START_URL); + return this.tabs.get(context); + } + + private found(id: string, url: string): void { + if (this.tabs.has(id)) return; + this.tabs.add({ info: { id, title: '', url } }); + this.deps.changed(); + } + + /** The browser was quit: its tabs are gone with it. */ + private closed(): void { + this.dispose(); + this.tabs.clear(); + this.deps.closed(); + } +} diff --git a/src/main/browsers/driven/firefox/captureContext.ts b/src/main/browsers/driven/firefox/captureContext.ts new file mode 100644 index 0000000..f11f27e --- /dev/null +++ b/src/main/browsers/driven/firefox/captureContext.ts @@ -0,0 +1,23 @@ +import type { CaptureArea } from '../../../../shared/types'; +import { BIDI, type BidiConnection } from '../../../engine/bidi'; +import type { CapturedImage } from '../../../shots/capture'; +import { MAX_TEXTURE_SIDE } from '../../../shots/constants'; +import { readPngSize } from '../../../shots/readPngSize'; +import { METRICS_EXPRESSION, SCREENSHOT_ORIGIN } from './constants'; +import { evaluateIn } from './evaluateIn'; + +/** + * Captures a Firefox tab as a PNG in device pixels: what its viewport shows, or its whole document (down to the + * height the app captures at most). Its scale is the image's pixels per CSS pixel; its viewport, the window's size. + */ +export async function captureContext(connection: BidiConnection, context: string, area: Exclude): Promise { + const answer: unknown = JSON.parse((await evaluateIn(connection, context, METRICS_EXPRESSION)) ?? '[]'); + const numbers = Array.isArray(answer) && answer.length === 5 && answer.every((n) => typeof n === 'number' && n > 0) ? (answer as number[]) : [0, 0, 1, 0, 0]; + const [width, height, ratio, documentWidth, documentHeight] = numbers; + const clip = area === 'page' && documentWidth ? { type: 'box', x: 0, y: 0, width: documentWidth, height: Math.min(documentHeight, Math.floor(MAX_TEXTURE_SIDE / ratio)) } : null; + const { data } = await connection.send<{ data: string }>(BIDI.browsingContext.captureScreenshot, { context, origin: SCREENSHOT_ORIGIN[area], ...(clip ? { clip } : {}) }); + const bytes = Buffer.from(data, 'base64'); + const size = readPngSize(bytes); + const scale = clip ? size.width / clip.width : ratio; + return { bytes, ...size, scale, viewport: { width: width || Math.round(size.width / scale), height: height || Math.round(size.height / scale) } }; +} diff --git a/src/main/browsers/driven/firefox/captureContextAt.ts b/src/main/browsers/driven/firefox/captureContextAt.ts new file mode 100644 index 0000000..b825a4b --- /dev/null +++ b/src/main/browsers/driven/firefox/captureContextAt.ts @@ -0,0 +1,23 @@ +import { BIDI, type BidiConnection } from '../../../engine/bidi'; +import { withTimeout } from '../../../engine/PageInterception'; +import type { CapturedImage, Viewport } from '../../../shots/capture'; +import { LOADED_EXPRESSION, SETTLE_EXPRESSION } from '../../../shots/constants'; +import { LOAD_TIMEOUT_MS } from '../constants'; +import { captureContext } from './captureContext'; +import { evaluateIn } from './evaluateIn'; + +/** + * Captures a Firefox tab's whole page laid out in `viewport` (size and density), once it has loaded and been quiet a + * moment; the tab gets its own viewport back afterwards. + */ +export async function captureContextAt(connection: BidiConnection, context: string, { width, height, scale }: Viewport): Promise { + // A page still loading after that is captured as it is. + await withTimeout(evaluateIn(connection, context, LOADED_EXPRESSION, true), LOAD_TIMEOUT_MS, 'Loading the page').catch(() => undefined); + await connection.send(BIDI.browsingContext.setViewport, { context, viewport: { width, height }, devicePixelRatio: scale }); + try { + await evaluateIn(connection, context, SETTLE_EXPRESSION, true); + return await captureContext(connection, context, 'page'); + } finally { + await connection.send(BIDI.browsingContext.setViewport, { context, viewport: null, devicePixelRatio: null }).catch(() => undefined); + } +} diff --git a/src/main/browsers/driven/firefox/connectFirefox.ts b/src/main/browsers/driven/firefox/connectFirefox.ts new file mode 100644 index 0000000..1d82ecb --- /dev/null +++ b/src/main/browsers/driven/firefox/connectFirefox.ts @@ -0,0 +1,18 @@ +import { BIDI, BidiConnection } from '../../../engine/bidi'; +import type { FoundBrowser } from '../../types'; +import { profileDir } from '../profileDir'; +import { reachOrLaunch } from '../reachOrLaunch'; +import type { Driver, DriverDeps } from '../types'; +import { DrivenFirefox } from './DrivenFirefox'; +import { launchFirefox } from './launchFirefox'; +import { readBidiPort } from './readBidiPort'; + +/** Drives Firefox over WebDriver BiDi: the one still open with the app's profile for it, or one launched now. */ +export async function connectFirefox(browser: FoundBrowser, deps: DriverDeps): Promise { + const dir = profileDir(browser, deps.userData); + const connection = await reachOrLaunch(() => readBidiPort(dir), (address) => BidiConnection.open(address), () => launchFirefox(browser, dir)); + const { capabilities } = await connection.send<{ capabilities: { browserVersion?: string } }>(BIDI.session.new, { capabilities: {} }); + const driver = new DrivenFirefox(browser, capabilities.browserVersion ?? null, connection, deps); + await driver.start(); + return driver; +} diff --git a/src/main/browsers/driven/firefox/constants.ts b/src/main/browsers/driven/firefox/constants.ts new file mode 100644 index 0000000..a7f8457 --- /dev/null +++ b/src/main/browsers/driven/firefox/constants.ts @@ -0,0 +1,38 @@ +/** The file Firefox writes in its profile once its WebDriver BiDi server listens: `{ "ws_host", "ws_port" }`. */ +export const BIDI_PORT_FILE = 'WebDriverBiDiServer.json'; + +/** A BiDi session's path on that server. */ +export const SESSION_PATH = '/session'; + +/** + * What Firefox started to be driven is given: its profile (the folder follows the flag), its own instance, and a + * debugging port the system picks. + */ +export const PROFILE_FLAG = '--profile'; +export const FIREFOX_FLAGS = ['--no-remote', '--new-instance', '--remote-debugging-port=0'] as const; + +/** The preferences file of a Firefox profile, read at each start. */ +export const USER_PREFS_FILE = 'user.js'; + +/** A driven Firefox's preferences: no first-run pages, no default-browser question, no data reporting. */ +export const FIREFOX_PREFS: ReadonlyArray = [ + ['browser.shell.checkDefaultBrowser', false], + ['browser.startup.homepage_override.mstone', 'ignore'], + ['browser.aboutwelcome.enabled', false], + ['datareporting.policy.dataSubmissionEnabled', false], + ['toolkit.telemetry.reportingpolicy.firstRun', false], + ['browser.tabs.warnOnClose', false], +]; + +/** A tab's title, and its window (CSS pixels), density and document size, read in its page (the latter as JSON). */ +export const TITLE_EXPRESSION = 'document.title'; +export const METRICS_EXPRESSION = 'JSON.stringify([innerWidth, innerHeight, devicePixelRatio, document.documentElement.scrollWidth, document.documentElement.scrollHeight])'; + +/** What a capture of each area is taken relative to. */ +export const SCREENSHOT_ORIGIN = { viewport: 'viewport', page: 'document' } as const; + +/** A new top-level context's type. */ +export const TAB_TYPE = 'tab'; + +/** How far `navigate` waits: not at all (opening a tab), or until the page has loaded (capturing it). */ +export const NAVIGATE_WAIT = { none: 'none', loaded: 'complete' } as const; diff --git a/src/main/browsers/driven/firefox/evaluateIn.ts b/src/main/browsers/driven/firefox/evaluateIn.ts new file mode 100644 index 0000000..4555549 --- /dev/null +++ b/src/main/browsers/driven/firefox/evaluateIn.ts @@ -0,0 +1,14 @@ +import { BIDI, type BidiConnection } from '../../../engine/bidi'; + +/** What `script.evaluate` answers, as far as a string result goes. */ +interface Evaluated { + type: string; + result?: { type: string; value?: unknown }; +} + +/** Runs `expression` in a tab's page (awaited when it is a promise); its value when that is a string, else null. */ +export async function evaluateIn(connection: BidiConnection, context: string, expression: string, awaitPromise = false): Promise { + const evaluated = await connection.send(BIDI.script.evaluate, { expression, target: { context }, awaitPromise }).catch(() => null); + const value = evaluated?.result?.value; + return typeof value === 'string' ? value : null; +} diff --git a/src/main/browsers/driven/firefox/launchFirefox.ts b/src/main/browsers/driven/firefox/launchFirefox.ts new file mode 100644 index 0000000..9bdabfd --- /dev/null +++ b/src/main/browsers/driven/firefox/launchFirefox.ts @@ -0,0 +1,14 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { FoundBrowser } from '../../types'; +import { launchDriven } from '../launchDriven'; +import { BIDI_PORT_FILE, FIREFOX_FLAGS, FIREFOX_PREFS, PROFILE_FLAG, USER_PREFS_FILE } from './constants'; +import { readBidiPort } from './readBidiPort'; + +/** Starts Firefox to be driven: its profile in `dir` (with the app's preferences), and a WebDriver BiDi port. */ +export async function launchFirefox(browser: FoundBrowser, dir: string): Promise { + await mkdir(dir, { recursive: true }); + const prefs = FIREFOX_PREFS.map(([name, value]) => `user_pref(${JSON.stringify(name)}, ${JSON.stringify(value)});`); + await writeFile(join(dir, USER_PREFS_FILE), `${prefs.join('\n')}\n`); + return launchDriven(browser, dir, { flags: [PROFILE_FLAG, dir, ...FIREFOX_FLAGS], portFile: BIDI_PORT_FILE, read: readBidiPort }); +} diff --git a/src/main/browsers/driven/firefox/readBidiPort.ts b/src/main/browsers/driven/firefox/readBidiPort.ts new file mode 100644 index 0000000..fe564ae --- /dev/null +++ b/src/main/browsers/driven/firefox/readBidiPort.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { BIDI_PORT_FILE, SESSION_PATH } from './constants'; + +/** The WebDriver BiDi address of the Firefox whose profile is `dir`, from the file it wrote there; null when there is none (yet). */ +export async function readBidiPort(dir: string): Promise { + const text = await readFile(join(dir, BIDI_PORT_FILE), 'utf8').catch(() => ''); + try { + const { ws_host: host, ws_port: port } = JSON.parse(text) as { ws_host?: unknown; ws_port?: unknown }; + return typeof host === 'string' && Number.isInteger(port) ? `ws://${host}:${String(port)}${SESSION_PATH}` : null; + } catch { + return null; + } +} diff --git a/src/main/browsers/driven/firefox/readFirefoxTabs.ts b/src/main/browsers/driven/firefox/readFirefoxTabs.ts new file mode 100644 index 0000000..9e79b6f --- /dev/null +++ b/src/main/browsers/driven/firefox/readFirefoxTabs.ts @@ -0,0 +1,16 @@ +import { BIDI, type BidiConnection } from '../../../engine/bidi'; +import { TITLE_EXPRESSION } from './constants'; +import { evaluateIn } from './evaluateIn'; +import type { TabRead } from '../types'; +import type { ContextInfo } from './types'; + +/** Each tab's address (from the tree) and title (asked of its page, which BiDi doesn't report). */ +export async function readFirefoxTabs(connection: BidiConnection, ids: string[]): Promise { + const { contexts } = await connection.send<{ contexts: ContextInfo[] }>(BIDI.browsingContext.getTree, { maxDepth: 0 }).catch(() => ({ contexts: [] as ContextInfo[] })); + const titles = await Promise.all(ids.map((id) => evaluateIn(connection, id, TITLE_EXPRESSION))); + return ids.map((id, i) => { + const url = contexts.find((c) => c.context === id)?.url; + const title = titles[i]; + return { id, ...(url === undefined ? {} : { url }), ...(title === null ? {} : { title }) }; + }); +} diff --git a/src/main/browsers/driven/firefox/types.ts b/src/main/browsers/driven/firefox/types.ts new file mode 100644 index 0000000..11e236f --- /dev/null +++ b/src/main/browsers/driven/firefox/types.ts @@ -0,0 +1,6 @@ +/** What BiDi says of a browsing context: a tab's (no parent) or a frame's. */ +export interface ContextInfo { + context: string; + url: string; + parent?: string | null; +} diff --git a/src/main/browsers/driven/launchDriven.ts b/src/main/browsers/driven/launchDriven.ts new file mode 100644 index 0000000..1dd4ab5 --- /dev/null +++ b/src/main/browsers/driven/launchDriven.ts @@ -0,0 +1,26 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { startBrowser } from '../startBrowser'; +import type { FoundBrowser } from '../types'; +import { PORT_WAIT, START_URL } from './constants'; +import { driveCommand } from './driveCommand'; +import type { LaunchSpec } from './types'; + +/** + * Starts a browser to be driven, its profile in `dir`, with `flags` (a debugging port among them) on a blank page; + * resolves with the address it writes in its profile, once written. A file left there by an earlier run names a port + * no longer listened on: it goes first. + */ +export async function launchDriven(browser: FoundBrowser, dir: string, { flags, portFile, read }: LaunchSpec): Promise { + await mkdir(dir, { recursive: true }); + await rm(join(dir, portFile), { force: true }); + const [program, ...args] = driveCommand(browser, flags, START_URL); + await startBrowser({ command: [program, ...args], urlAt: args.length + 1 }, []); + const deadline = Date.now() + PORT_WAIT.timeoutMs; + for (;;) { + const address = await read(dir); + if (address) return address; + if (Date.now() > deadline) throw new Error(`${browser.name} didn't open its debugging port`); + await new Promise((resolve) => setTimeout(resolve, PORT_WAIT.stepMs)); + } +} diff --git a/src/main/browsers/driven/launchForDriving.ts b/src/main/browsers/driven/launchForDriving.ts deleted file mode 100644 index 10deedc..0000000 --- a/src/main/browsers/driven/launchForDriving.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { mkdir, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { LOCAL_NETWORK_ACCESS_FEATURES } from '../../chromiumFlags'; -import { startBrowser } from '../startBrowser'; -import type { FoundBrowser } from '../types'; -import { ACTIVE_PORT_FILE, ACTIVE_PORT_WAIT, DISABLE_FEATURES_FLAG, DRIVE_FLAGS, START_URL, USER_DATA_FLAG } from './constants'; -import { driveCommand } from './driveCommand'; -import { readActivePort } from './readActivePort'; - -/** - * Starts a Chromium browser to be driven: with its profile in `dir`, a debugging port, the app's Chromium switches, on - * a blank page; resolves with its WebSocket address once it has written it. - */ -export async function launchForDriving(browser: FoundBrowser, dir: string): Promise { - await mkdir(dir, { recursive: true }); - // A file left by an earlier run names a port no longer listened on. - await rm(join(dir, ACTIVE_PORT_FILE), { force: true }); - const flags = [`${USER_DATA_FLAG}${dir}`, ...DRIVE_FLAGS, `${DISABLE_FEATURES_FLAG}${LOCAL_NETWORK_ACCESS_FEATURES.join(',')}`]; - const [program, ...args] = driveCommand(browser, flags, START_URL); - await startBrowser({ command: [program, ...args], urlAt: args.length + 1 }, []); - const deadline = Date.now() + ACTIVE_PORT_WAIT.timeoutMs; - for (;;) { - const address = await readActivePort(dir); - if (address) return address; - if (Date.now() > deadline) throw new Error(`${browser.name} didn't open its debugging port`); - await new Promise((resolve) => setTimeout(resolve, ACTIVE_PORT_WAIT.stepMs)); - } -} diff --git a/src/main/browsers/driven/reachOrLaunch.ts b/src/main/browsers/driven/reachOrLaunch.ts index 9144c8f..ffa9a81 100644 --- a/src/main/browsers/driven/reachOrLaunch.ts +++ b/src/main/browsers/driven/reachOrLaunch.ts @@ -1,15 +1,10 @@ -import { CdpConnection } from '../../engine/websocketTransport'; -import type { FoundBrowser } from '../types'; -import { launchForDriving } from './launchForDriving'; -import { readActivePort } from './readActivePort'; - /** - * A connection to the browser driven with the profile in `dir`: the one the app launched before, if it is still open - * (the app was restarted, or stopped driving it), else one launched now. A browser already open with that profile - * would take a second launch's address itself, and never open a debugging port for it. + * A connection to the browser driven with a profile: the one the app launched before, if it is still open (the app was + * restarted, or let go of it), else one launched now. A browser already open with that profile would take a second + * launch's address itself, and never open a debugging port for it. */ -export async function reachOrLaunch(browser: FoundBrowser, dir: string): Promise { - const running = await readActivePort(dir); - const reached = running ? await CdpConnection.connect(running).catch(() => null) : null; - return reached ?? CdpConnection.connect(await launchForDriving(browser, dir)); +export async function reachOrLaunch(read: () => Promise, connect: (address: string) => Promise, launch: () => Promise): Promise { + const running = await read(); + const reached = running ? await connect(running).catch(() => null) : null; + return reached ?? connect(await launch()); } diff --git a/src/main/browsers/driven/shotBrowser.ts b/src/main/browsers/driven/shotBrowser.ts index 81172bb..ba565de 100644 --- a/src/main/browsers/driven/shotBrowser.ts +++ b/src/main/browsers/driven/shotBrowser.ts @@ -1,8 +1,8 @@ import type { ShotBrowser } from '../../../shared/types'; -import type { DrivenChromium } from './DrivenChromium'; +import type { Driver } from './types'; /** A driven browser, as the browser a capture was taken in. */ -export function shotBrowser(driven: DrivenChromium): ShotBrowser { - const { id, name, version } = driven.list(); +export function shotBrowser(driver: Driver): ShotBrowser { + const { id, name, version } = driver.list(); return { id, name, version }; } diff --git a/src/main/browsers/driven/types.ts b/src/main/browsers/driven/types.ts index 3e8e881..4ac58f9 100644 --- a/src/main/browsers/driven/types.ts +++ b/src/main/browsers/driven/types.ts @@ -1,6 +1,5 @@ -import type { AppEvent, DrivenTab } from '../../../shared/types'; -import type { PageInterception } from '../../engine/PageInterception'; -import type { PageTransport } from '../../engine/websocketTransport'; +import type { AppEvent, CaptureArea, DrivenBrowser, DrivenTab } from '../../../shared/types'; +import type { CapturedImage, Viewport } from '../../shots/capture'; import type { OverrideStore } from '../../store/OverrideStore'; import type { RuleStore } from '../../store/RuleStore'; import type { SettingsStore } from '../../store/SettingsStore'; @@ -13,37 +12,69 @@ export interface InterceptionSources { settings: SettingsStore; } -/** A tab of a driven browser, with the interception serving it. */ -export interface DrivenTabState { - info: DrivenTab; - sessionId: string; - transport: PageTransport; - interception: PageInterception; - /** Settles once its interception is set up (or couldn't be) and the tab runs. */ - ready: Promise; +/** A capture of a driven browser's tab, with the address it showed. */ +export interface TabCapture { + image: CapturedImage; + url: string; } -/** What `Target.attachedToTarget` and `Target.targetInfoChanged` say of a target, as far as a tab needs. */ -export interface PageTargetInfo { - targetId: string; - type: string; - url: string; - title: string; +/** A tab's address and title as read again, each when it could be. */ +export interface TabRead { + id: string; + url?: string; + title?: string; +} + +/** A tab as a driven browser keeps it: what is listed of it, and whatever its driver needs besides. */ +export interface KeptTab { + info: DrivenTab; } -export interface AttachedPage { - sessionId: string; - targetInfo: PageTargetInfo; +/** A browser the app drives, over whichever protocol its engine speaks (CDP for Chromium, WebDriver BiDi for Firefox). */ +export interface Driver { + readonly browser: FoundBrowser; + /** Attaches to its tabs, the ones open now and every one opened later. */ + start(): Promise; + list(): DrivenBrowser; + /** Reads each tab's title and address again. */ + readTabs(): Promise; + /** Opens an address in its blank tab, or a new one, and brings it to the front. */ + open(url: string): Promise; + activate(tabId: string): Promise; + capture(tabId: string, area: Exclude): Promise; + /** Captures the whole page at `url` (in the tab showing it, or one opened there) laid out in `viewport`. */ + captureAt(url: string, viewport: Viewport): Promise; + /** After overrides or rules changed. */ + refresh(): Promise; + /** After the settings changed. */ + applySettings(): Promise; + /** Reloads its tabs showing a website. */ + reload(): Promise; + /** Stops serving the workspace's changes; the browser stays open. */ + stop(): void; } -export interface DrivenChromiumDeps { +export interface DriverDeps { sources: InterceptionSources; + /** The app's data folder, where driven browsers keep their profiles. */ + userData: string; /** A tab opened, closed, or changed its address or title. */ changed(): void; /** The browser went away (it was quit). */ closed(): void; } +/** Launches (or reaches again) a browser to drive, and starts driving it. */ +export type ConnectDriver = (browser: FoundBrowser, deps: DriverDeps) => Promise; + +/** How a browser is launched to be driven: its flags, and the file in its profile it writes its address in. */ +export interface LaunchSpec { + flags: string[]; + portFile: string; + /** The address from that file, once written. */ + read(dir: string): Promise; +} + export interface DrivenBrowsersDeps { /** Where the browsers are found. */ registry: { get(id: string): Promise }; diff --git a/src/main/engine/answering/answerContext.ts b/src/main/engine/answering/answerContext.ts new file mode 100644 index 0000000..178264f --- /dev/null +++ b/src/main/engine/answering/answerContext.ts @@ -0,0 +1,8 @@ +import { MatcherCache } from '../InterceptionEngine'; +import { OverrideMatcher } from '../InterceptionEngine/OverrideMatcher'; +import type { AnswerContext, AnswerSources } from './types'; + +/** A context to answer requests from `sources` with, its matchers compiled as they are needed. */ +export function answerContext(sources: AnswerSources): AnswerContext { + return { sources, overrides: new OverrideMatcher({ getOverrides: () => sources.getOverrides() }), matchers: new MatcherCache() }; +} diff --git a/src/main/engine/answering/constants.ts b/src/main/engine/answering/constants.ts new file mode 100644 index 0000000..9d4f1a5 --- /dev/null +++ b/src/main/engine/answering/constants.ts @@ -0,0 +1,5 @@ +/** The resource type a CORS preflight is looked up as: it asks ahead of a fetch() or XHR, which only such overrides answer. */ +export const PREFLIGHT_RESOURCE = 'Fetch'; + +/** The request header that makes a request cross-origin-readable only with CORS headers. */ +export const ORIGIN_HEADER = 'origin'; diff --git a/src/main/engine/answering/decideRequest.ts b/src/main/engine/answering/decideRequest.ts new file mode 100644 index 0000000..7a46790 --- /dev/null +++ b/src/main/engine/answering/decideRequest.ts @@ -0,0 +1,24 @@ +import { overrideBody } from '../InterceptionEngine/overrideBody'; +import { applyCors, findBlockRule, isPreflight, preflightMethod, type PausedRequest } from '../rules'; +import { PREFLIGHT_STATUS } from '../rules/constants'; +import { PREFLIGHT_RESOURCE } from './constants'; +import { servedHead } from './servedHead'; +import type { AnswerContext, RequestDecision } from './types'; + +/** + * What to do with a request before it is sent, where a browser can only replace a response then (Firefox, WebKit): + * fail it when a block rule takes it; allow a CORS preflight ahead of a request an override answers; answer a request + * an override takes with it; send anything else on. A request's body isn't known, so an override naming a GraphQL + * operation never answers. + */ +export function decideRequest(ctx: AnswerContext, request: PausedRequest, resourceType: string): RequestDecision { + const { sources, overrides, matchers } = ctx; + if (findBlockRule(sources.getRules(), request.url, resourceType, matchers)) return { action: 'fail' }; + const method = isPreflight(request) ? preflightMethod(request) : undefined; + if (method && overrides.find(request.url, PREFLIGHT_RESOURCE, { method, operation: () => undefined })) { + return { action: 'answer', head: applyCors({ status: PREFLIGHT_STATUS, headers: [] }, request), body: '', delayMs: 0 }; + } + const override = overrides.find(request.url, resourceType, { method: request.method, operation: () => undefined }); + if (!override) return { action: 'continue' }; + return { action: 'answer', head: servedHead(override, request, resourceType, ctx), body: overrideBody(override, sources.getSettings()), delayMs: override.response?.delayMs ?? 0 }; +} diff --git a/src/main/engine/answering/index.ts b/src/main/engine/answering/index.ts new file mode 100644 index 0000000..365feee --- /dev/null +++ b/src/main/engine/answering/index.ts @@ -0,0 +1,4 @@ +export { answerContext } from './answerContext'; +export { decideRequest } from './decideRequest'; +export { ruledHead } from './ruledHead'; +export type { AnswerContext, AnswerSources, RequestDecision } from './types'; diff --git a/src/main/engine/answering/ruledHead.ts b/src/main/engine/answering/ruledHead.ts new file mode 100644 index 0000000..08966d0 --- /dev/null +++ b/src/main/engine/answering/ruledHead.ts @@ -0,0 +1,9 @@ +import { applyResponseRules, findResponseRules, type PausedRequest, type ResponseHead } from '../rules'; +import type { AnswerContext } from './types'; + +/** A response's head with the response rules that take its request applied; null when none changed it. */ +export function ruledHead({ sources, matchers }: AnswerContext, request: PausedRequest, resourceType: string, head: ResponseHead): ResponseHead | null { + const rules = findResponseRules(sources.getRules(), request.url, resourceType, matchers); + const ruled = applyResponseRules(head, rules, request); + return ruled.applied.length ? ruled.head : null; +} diff --git a/src/main/engine/answering/servedHead.ts b/src/main/engine/answering/servedHead.ts new file mode 100644 index 0000000..845e277 --- /dev/null +++ b/src/main/engine/answering/servedHead.ts @@ -0,0 +1,18 @@ +import type { Override } from '../../../shared/types'; +import { OVERRIDE_STATUS } from '../InterceptionEngine/constants'; +import { applyCors, applyHeaderEdits, applyResponseRules, findResponseRules, type PausedRequest, type ResponseHead } from '../rules'; +import { buildOverrideHeaders } from '../transform'; +import { ORIGIN_HEADER } from './constants'; +import type { AnswerContext } from './types'; + +/** + * The head an override answers with before its request is sent (where a browser can't replace a body once upstream + * has answered): its kind's own headers, with no upstream's to start from; a response override's status and header + * changes; readable cross-origin when the request came from another origin; then the response rules. + */ +export function servedHead(override: Override, request: PausedRequest, resourceType: string, { sources, matchers }: AnswerContext): ResponseHead { + const headers = buildOverrideHeaders(undefined, override.kind, sources.getSettings()); + const own: ResponseHead = override.response ? { status: override.response.status, headers: applyHeaderEdits(headers, override.response.headers) } : { status: OVERRIDE_STATUS, headers }; + const readable = request.headers[ORIGIN_HEADER] ? applyCors(own, request) : own; + return applyResponseRules(readable, findResponseRules(sources.getRules(), request.url, resourceType, matchers), request).head; +} diff --git a/src/main/engine/answering/types.ts b/src/main/engine/answering/types.ts new file mode 100644 index 0000000..a3acf85 --- /dev/null +++ b/src/main/engine/answering/types.ts @@ -0,0 +1,21 @@ +import type { Override, Rule, Settings } from '../../../shared/types'; +import type { MatcherCache } from '../InterceptionEngine'; +import type { OverrideMatcher } from '../InterceptionEngine/OverrideMatcher'; +import type { ResponseHead } from '../rules'; + +/** What answering a request reads each time: the active workspace's overrides and rules, and the settings. */ +export interface AnswerSources { + getOverrides(): Override[]; + getRules(): readonly Rule[]; + getSettings(): Settings; +} + +/** What answering a request works with: the sources, and the matchers compiled from them. */ +export interface AnswerContext { + sources: AnswerSources; + overrides: OverrideMatcher; + matchers: MatcherCache; +} + +/** What to do with a request before it is sent: fail it, answer it (after a delay) without sending it, or send it on. */ +export type RequestDecision = { action: 'fail' } | { action: 'answer'; head: ResponseHead; body: string; delayMs: number } | { action: 'continue' }; diff --git a/src/main/engine/bidi/BidiConnection.ts b/src/main/engine/bidi/BidiConnection.ts new file mode 100644 index 0000000..d19e499 --- /dev/null +++ b/src/main/engine/bidi/BidiConnection.ts @@ -0,0 +1,83 @@ +import { MESSAGE_TYPE } from './constants'; +import type { BidiEventHandler } from './types'; + +/** A WebDriver BiDi message from the browser: a command's result or error, or an event. */ +interface Incoming { + type: string; + id?: number; + result?: unknown; + error?: string; + message?: string; + method?: string; + params?: unknown; +} + +/** + * A WebDriver BiDi session's connection over WebSocket (`ws://…/session`): commands answered by id, events to every + * handler, and every command in flight failed once it closes. How the app drives Firefox. + */ +export class BidiConnection { + private lastId = 0; + private readonly waiting = new Map(); + private readonly listeners = new Set(); + private readonly closeListeners = new Set<() => void>(); + + private constructor(private readonly socket: WebSocket) { + socket.addEventListener('message', (event) => this.receive(JSON.parse(String(event.data)) as Incoming)); + socket.addEventListener('close', () => { + for (const [, { settle, method }] of this.waiting) settle({ type: MESSAGE_TYPE.error, error: 'closed', message: `The browser went away during ${method}` }); + this.waiting.clear(); + for (const listener of this.closeListeners) listener(); + }); + } + + static open(url: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + socket.addEventListener('open', () => resolve(new BidiConnection(socket)), { once: true }); + socket.addEventListener('error', () => reject(new Error(`Could not reach ${url}`)), { once: true }); + }); + } + + send(method: string, params: Record = {}): Promise { + const id = ++this.lastId; + return new Promise((resolve, reject) => { + const settle = (message: Incoming) => (message.type === MESSAGE_TYPE.success ? resolve(message.result as T) : reject(new Error(`${method}: ${message.error}: ${message.message ?? ''}`))); + this.waiting.set(id, { settle, method }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + onEvent(handler: BidiEventHandler): () => void { + this.listeners.add(handler); + return () => this.listeners.delete(handler); + } + + /** Called once the connection has closed: closed here, or the browser went away. */ + onClose(handler: () => void): () => void { + this.closeListeners.add(handler); + return () => this.closeListeners.delete(handler); + } + + close(): void { + this.socket.close(); + } + + private receive(message: Incoming): void { + if (message.type === MESSAGE_TYPE.event && message.method) { + // A handler that throws must not keep the others from the event. + for (const listener of this.listeners) { + try { + listener(message.method, message.params); + } catch (err) { + console.error('BiDi event handler failed', message.method, err); + } + } + return; + } + const pending = message.id === undefined ? undefined : this.waiting.get(message.id); + if (!pending || message.id === undefined) return; + this.waiting.delete(message.id); + pending.settle(message); + } +} diff --git a/src/main/engine/bidi/BidiInterception.ts b/src/main/engine/bidi/BidiInterception.ts new file mode 100644 index 0000000..a6a5c97 --- /dev/null +++ b/src/main/engine/bidi/BidiInterception.ts @@ -0,0 +1,70 @@ +import { answerContext, type AnswerSources } from '../answering'; +import { answerRequest } from './answerRequest'; +import { answerResponse } from './answerResponse'; +import { BIDI, CACHE_BEHAVIOR, INTERCEPT_PHASES } from './constants'; +import type { BidiConnection } from './BidiConnection'; +import type { BidiAnswerContext, BidiNetworkEvent } from './types'; + +/** What answers a request paused at each phase. */ +const ANSWERS: Readonly Promise>> = { + [BIDI.network.beforeRequestSent]: answerRequest, + [BIDI.network.responseStarted]: answerResponse, +}; + +/** + * The workspace's overrides and rules in every tab of a browser driven over WebDriver BiDi (Firefox): the engine's + * matching and rules on each paused request, requests paused only while an override or a rule is on, and the cache + * bypassed then (and when the settings say so) so every request is seen. + */ +export class BidiInterception { + private readonly ctx: BidiAnswerContext; + private intercept: string | null = null; + private updating = Promise.resolve(); + private off: (() => void) | undefined; + + constructor(connection: BidiConnection, sources: AnswerSources) { + this.ctx = { ...answerContext(sources), connection }; + } + + async start(): Promise { + this.off = this.ctx.connection.onEvent((method, params: BidiNetworkEvent) => { + // A request gone meanwhile (the tab closed, or it was answered already) can't be answered: nothing to do. + if (params?.isBlocked && Object.hasOwn(ANSWERS, method)) void ANSWERS[method](this.ctx, params).catch(() => undefined); + }); + await this.ctx.connection.send(BIDI.session.subscribe, { events: Object.keys(ANSWERS) }); + await this.refresh(); + } + + /** After overrides or rules changed: requests are paused while any is on, and matched afresh. */ + refresh(): Promise { + this.updating = this.updating.then(() => this.update()).catch(() => undefined); + return this.updating; + } + + /** After the settings changed: the cache used or bypassed. */ + async applySettings(): Promise { + const bypass = this.intercept !== null || this.ctx.sources.getSettings().disableCache; + await this.ctx.connection.send(BIDI.network.setCacheBehavior, { cacheBehavior: bypass ? CACHE_BEHAVIOR.bypass : CACHE_BEHAVIOR.normal }); + } + + /** Stops pausing requests; the connection stays. */ + stop(): void { + this.off?.(); + if (this.intercept) this.ctx.connection.send(BIDI.network.removeIntercept, { intercept: this.intercept }).catch(() => undefined); + this.intercept = null; + } + + private async update(): Promise { + const { connection, sources, overrides, matchers } = this.ctx; + overrides.clear(); + matchers.clear(); + const needed = sources.getOverrides().some((o) => o.enabled) || sources.getRules().some((r) => r.enabled); + if (needed && !this.intercept) this.intercept = (await connection.send<{ intercept: string }>(BIDI.network.addIntercept, { phases: [...INTERCEPT_PHASES] })).intercept; + if (!needed && this.intercept) { + const intercept = this.intercept; + this.intercept = null; + await connection.send(BIDI.network.removeIntercept, { intercept }).catch(() => undefined); + } + await this.applySettings(); + } +} diff --git a/src/main/engine/bidi/answerRequest.ts b/src/main/engine/bidi/answerRequest.ts new file mode 100644 index 0000000..b8077d7 --- /dev/null +++ b/src/main/engine/bidi/answerRequest.ts @@ -0,0 +1,24 @@ +import { decideRequest, type RequestDecision } from '../answering'; +import { holdFor } from '../InterceptionEngine/holdFor'; +import { BIDI } from './constants'; +import { provideResponse } from './provideResponse'; +import { requestOf } from './requestOf'; +import { resourceTypeOf } from './resourceTypeOf'; +import type { BidiAnswerContext, BidiNetworkEvent } from './types'; + +/** How each decision is carried out over BiDi. */ +const CARRY_OUT: { [A in RequestDecision['action']]: (ctx: BidiAnswerContext, request: string, decision: Extract) => Promise } = { + fail: ({ connection }, request) => connection.send(BIDI.network.failRequest, { request }), + continue: ({ connection }, request) => connection.send(BIDI.network.continueRequest, { request }), + answer: async ({ connection }, request, { head, body, delayMs }) => { + await holdFor(delayMs); + await provideResponse(connection, request, head, body); + }, +}; + +/** Answers a request paused before it is sent, as {@link decideRequest} decides. */ +export async function answerRequest(ctx: BidiAnswerContext, { request: data }: BidiNetworkEvent): Promise { + const decision = decideRequest(ctx, requestOf(data), resourceTypeOf(data)); + const carryOut = CARRY_OUT[decision.action] as (ctx: BidiAnswerContext, request: string, decision: RequestDecision) => Promise; + await carryOut(ctx, data.request, decision); +} diff --git a/src/main/engine/bidi/answerResponse.ts b/src/main/engine/bidi/answerResponse.ts new file mode 100644 index 0000000..05b0fac --- /dev/null +++ b/src/main/engine/bidi/answerResponse.ts @@ -0,0 +1,15 @@ +import { STATUS_CODES } from 'node:http'; +import { ruledHead } from '../answering'; +import { bidiHeaders } from './bidiHeaders'; +import { BIDI } from './constants'; +import { headerEntriesOf } from './headerEntriesOf'; +import { requestOf } from './requestOf'; +import { resourceTypeOf } from './resourceTypeOf'; +import type { BidiAnswerContext, BidiNetworkEvent } from './types'; + +/** Lets a response paused at its head go on, with the response rules that take it applied to its status and headers. */ +export async function answerResponse(ctx: BidiAnswerContext, { request: data, response }: BidiNetworkEvent): Promise { + const head = response ? ruledHead(ctx, requestOf(data), resourceTypeOf(data), { status: response.status, headers: headerEntriesOf(response.headers) }) : null; + const changed = head ? { statusCode: head.status, reasonPhrase: STATUS_CODES[head.status] ?? '', headers: bidiHeaders(head.headers) } : {}; + await ctx.connection.send(BIDI.network.continueResponse, { request: data.request, ...changed }); +} diff --git a/src/main/engine/bidi/bidiHeaders.ts b/src/main/engine/bidi/bidiHeaders.ts new file mode 100644 index 0000000..5f14380 --- /dev/null +++ b/src/main/engine/bidi/bidiHeaders.ts @@ -0,0 +1,7 @@ +import type { HeaderEntry } from '../transform'; +import type { BidiHeader } from './types'; + +/** Headers as BiDi takes them. */ +export function bidiHeaders(headers: readonly HeaderEntry[]): BidiHeader[] { + return headers.map(({ name, value }) => ({ name, value: { type: 'string', value } })); +} diff --git a/src/main/engine/bidi/constants.ts b/src/main/engine/bidi/constants.ts new file mode 100644 index 0000000..0b627e1 --- /dev/null +++ b/src/main/engine/bidi/constants.ts @@ -0,0 +1,64 @@ +/** WebDriver BiDi's commands and events, as the protocol spells them. */ +export const BIDI = { + session: { new: 'session.new', end: 'session.end', subscribe: 'session.subscribe' }, + browsingContext: { + activate: 'browsingContext.activate', + captureScreenshot: 'browsingContext.captureScreenshot', + create: 'browsingContext.create', + getTree: 'browsingContext.getTree', + navigate: 'browsingContext.navigate', + reload: 'browsingContext.reload', + setViewport: 'browsingContext.setViewport', + // Events + contextCreated: 'browsingContext.contextCreated', + contextDestroyed: 'browsingContext.contextDestroyed', + fragmentNavigated: 'browsingContext.fragmentNavigated', + historyUpdated: 'browsingContext.historyUpdated', + load: 'browsingContext.load', + navigationStarted: 'browsingContext.navigationStarted', + }, + network: { + addIntercept: 'network.addIntercept', + continueRequest: 'network.continueRequest', + continueResponse: 'network.continueResponse', + failRequest: 'network.failRequest', + provideResponse: 'network.provideResponse', + removeIntercept: 'network.removeIntercept', + setCacheBehavior: 'network.setCacheBehavior', + // Events + beforeRequestSent: 'network.beforeRequestSent', + responseStarted: 'network.responseStarted', + }, + script: { evaluate: 'script.evaluate' }, +} as const; + +/** The phases the interception pauses requests at: before they are sent (to block or answer them), and at their response's head. */ +export const INTERCEPT_PHASES = ['beforeRequestSent', 'responseStarted'] as const; + +/** Cache behaviours: the network cache used as usual, or bypassed (so every request reaches the interception). */ +export const CACHE_BEHAVIOR = { normal: 'default', bypass: 'bypass' } as const; + +/** A BiDi message's type. */ +export const MESSAGE_TYPE = { success: 'success', error: 'error', event: 'event' } as const; + +/** + * The CDP resource type the engine's matching knows for a request's `destination`; a request with none (fetch, XHR) + * goes by its `initiatorType`, and anything else is `Other`. + */ +export const RESOURCE_TYPES: Readonly> = { + document: 'Document', + iframe: 'Document', + frame: 'Document', + style: 'Stylesheet', + script: 'Script', + image: 'Image', + font: 'Font', + audio: 'Media', + video: 'Media', + track: 'Media', + fetch: 'Fetch', + xmlhttprequest: 'XHR', + beacon: 'Ping', +}; + +export const OTHER_RESOURCE = 'Other'; diff --git a/src/main/engine/bidi/headerEntriesOf.ts b/src/main/engine/bidi/headerEntriesOf.ts new file mode 100644 index 0000000..6b26048 --- /dev/null +++ b/src/main/engine/bidi/headerEntriesOf.ts @@ -0,0 +1,7 @@ +import type { HeaderEntry } from '../transform'; +import type { BidiHeader } from './types'; + +/** BiDi's headers as the engine's (a binary value is kept as the base64 BiDi gives). */ +export function headerEntriesOf(headers: readonly BidiHeader[]): HeaderEntry[] { + return headers.map(({ name, value }) => ({ name, value: value.value })); +} diff --git a/src/main/engine/bidi/index.ts b/src/main/engine/bidi/index.ts new file mode 100644 index 0000000..f8b7c56 --- /dev/null +++ b/src/main/engine/bidi/index.ts @@ -0,0 +1,3 @@ +export { BidiConnection } from './BidiConnection'; +export { BIDI } from './constants'; +export { BidiInterception } from './BidiInterception'; diff --git a/src/main/engine/bidi/provideResponse.ts b/src/main/engine/bidi/provideResponse.ts new file mode 100644 index 0000000..5104e9f --- /dev/null +++ b/src/main/engine/bidi/provideResponse.ts @@ -0,0 +1,16 @@ +import { STATUS_CODES } from 'node:http'; +import type { ResponseHead } from '../rules'; +import { bidiHeaders } from './bidiHeaders'; +import { BIDI } from './constants'; +import type { BidiConnection } from './BidiConnection'; + +/** Answers a request paused before it was sent, with `head` and `body`: it never reaches the server. */ +export async function provideResponse(connection: BidiConnection, request: string, head: ResponseHead, body: string): Promise { + await connection.send(BIDI.network.provideResponse, { + request, + statusCode: head.status, + reasonPhrase: STATUS_CODES[head.status] ?? '', + headers: bidiHeaders(head.headers), + body: { type: 'string', value: body }, + }); +} diff --git a/src/main/engine/bidi/requestOf.ts b/src/main/engine/bidi/requestOf.ts new file mode 100644 index 0000000..4b5d11d --- /dev/null +++ b/src/main/engine/bidi/requestOf.ts @@ -0,0 +1,7 @@ +import type { PausedRequest } from '../rules'; +import type { BidiRequestData } from './types'; + +/** What rules and overrides read from a BiDi request: its address, method and headers by lower-case name (no body). */ +export function requestOf({ url, method, headers }: BidiRequestData): PausedRequest { + return { url, method, headers: Object.fromEntries(headers.map(({ name, value }) => [name.toLowerCase(), value.value])) }; +} diff --git a/src/main/engine/bidi/resourceTypeOf.ts b/src/main/engine/bidi/resourceTypeOf.ts new file mode 100644 index 0000000..0d9ade1 --- /dev/null +++ b/src/main/engine/bidi/resourceTypeOf.ts @@ -0,0 +1,8 @@ +import { OTHER_RESOURCE, RESOURCE_TYPES } from './constants'; +import type { BidiRequestData } from './types'; + +/** The CDP resource type the engine's matching knows for a BiDi request (see {@link RESOURCE_TYPES}). */ +export function resourceTypeOf({ destination, initiatorType }: BidiRequestData): string { + const key = destination || initiatorType || ''; + return Object.hasOwn(RESOURCE_TYPES, key) ? RESOURCE_TYPES[key] : OTHER_RESOURCE; +} diff --git a/src/main/engine/bidi/types.ts b/src/main/engine/bidi/types.ts new file mode 100644 index 0000000..f54a62b --- /dev/null +++ b/src/main/engine/bidi/types.ts @@ -0,0 +1,30 @@ +import type { AnswerContext } from '../answering'; +import type { BidiConnection } from './BidiConnection'; + +/** A header as BiDi carries it. */ +export interface BidiHeader { + name: string; + value: { type: 'string'; value: string } | { type: 'base64'; value: string }; +} + +/** What `network.beforeRequestSent` and `network.responseStarted` say of a request, as far as interception needs. */ +export interface BidiRequestData { + request: string; + url: string; + method: string; + headers: BidiHeader[]; + destination?: string; + initiatorType?: string | null; +} + +export interface BidiNetworkEvent { + context: string | null; + isBlocked: boolean; + request: BidiRequestData; + response?: { status: number; statusText: string; headers: BidiHeader[] }; +} + +export type BidiEventHandler = (method: string, params: any) => void; + +/** What answering a paused request over BiDi works with. */ +export type BidiAnswerContext = AnswerContext & { connection: BidiConnection }; diff --git a/src/renderer/src/entities/browser/index.ts b/src/renderer/src/entities/browser/index.ts index 8902283..d125f9b 100644 --- a/src/renderer/src/entities/browser/index.ts +++ b/src/renderer/src/entities/browser/index.ts @@ -1,2 +1,3 @@ +export { canDrive } from './lib'; export { selectDrivenTabCount, selectShownBrowsers, useBrowserStore } from './model/store'; export { BrowserIcon } from './ui/BrowserIcon'; diff --git a/src/renderer/src/entities/browser/lib/canDrive.ts b/src/renderer/src/entities/browser/lib/canDrive.ts new file mode 100644 index 0000000..352e027 --- /dev/null +++ b/src/renderer/src/entities/browser/lib/canDrive.ts @@ -0,0 +1,9 @@ +import { DRIVEN_ENGINES, type BrowserInfo } from '@common/types'; + +/** The engines the app drives, to look one up. */ +const DRIVEN: ReadonlySet = new Set(DRIVEN_ENGINES); + +/** Whether a browser can be opened with the workspace's changes (its engine is one the app drives). */ +export function canDrive(browser: Pick): boolean { + return DRIVEN.has(browser.engine); +} diff --git a/src/renderer/src/entities/browser/lib/index.ts b/src/renderer/src/entities/browser/lib/index.ts new file mode 100644 index 0000000..cc6e662 --- /dev/null +++ b/src/renderer/src/entities/browser/lib/index.ts @@ -0,0 +1 @@ +export { canDrive } from './canDrive'; diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx index d108f3c..f6be7b5 100644 --- a/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx @@ -1,7 +1,7 @@ import type { BrowserInfo } from '@common/types'; import { icons } from '@/shared/config'; import { IconButton } from '@/shared/ui/icon-button'; -import { BrowserIcon } from '@/entities/browser'; +import { BrowserIcon, canDrive } from '@/entities/browser'; export interface BrowserRowProps { browser: BrowserInfo; @@ -14,8 +14,8 @@ export interface BrowserRowProps { } /** - * A browser in the menu: its icon, name and version; choosing it opens the page there. A Chromium browser also offers - * opening it with the workspace's changes, in a profile of the app's own. + * A browser in the menu: its icon, name and version; choosing it opens the page there. A Chromium browser or Firefox + * also offers opening it with the workspace's changes, in a profile of the app's own. */ export function BrowserRow({ browser, disabled, driven, onOpen, onOpenWithChanges }: BrowserRowProps) { return ( @@ -31,7 +31,7 @@ export function BrowserRow({ browser, disabled, driven, onOpen, onOpenWithChange {driven ? : null} {browser.version ? {browser.version} : null} - {browser.engine === 'chromium' ? ( + {canDrive(browser) ? ( [ @@ -18,7 +19,7 @@ export function browserItems(browsers: BrowserInfo[], onWeb: boolean): CommandIt keywords: ['browser', 'other browser', browser.engine], onSelect: () => void openInBrowser(browser), }, - ...(browser.engine === 'chromium' + ...(canDrive(browser) ? [ { id: `${OPEN_CHANGED_PREFIX}${browser.id}`, diff --git a/src/shared/types/browsers.ts b/src/shared/types/browsers.ts index ce1b9ec..e18f3c8 100644 --- a/src/shared/types/browsers.ts +++ b/src/shared/types/browsers.ts @@ -3,6 +3,11 @@ export const BROWSER_ENGINES = ['chromium', 'gecko', 'webkit', 'unknown'] as con export type BrowserEngine = (typeof BROWSER_ENGINES)[number]; +/** The engines whose browsers the app can drive with the workspace's changes: Chromium's over CDP, Firefox over WebDriver BiDi. */ +export const DRIVEN_ENGINES = ['chromium', 'gecko'] as const satisfies readonly BrowserEngine[]; + +export type DrivenEngine = (typeof DRIVEN_ENGINES)[number]; + /** A browser installed on this computer, or one the user added. */ export interface BrowserInfo { /** Stable across scans: the desktop entry's, the app's or the registry key's name, or `added:<8 hex>`. */ diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 191e5d6..0d8290b 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -8,8 +8,8 @@ export type { ActionInput, ActionPatch, ActionsWindowState, ConsoleAction } from export type { Breakpoint, BreakpointStage, FailReason, HeldAction, HeldActionType, HeldRequest, HeldResponse } from './breakpoints'; export { BREAKPOINT_STAGES, FAIL_REASONS } from './breakpoints'; export type { ConsoleEditorApi } from './api'; -export type { BrowserEngine, BrowserInfo, DrivenBrowser, DrivenTab } from './browsers'; -export { BROWSER_ENGINES } from './browsers'; +export type { BrowserEngine, BrowserInfo, DrivenBrowser, DrivenEngine, DrivenTab } from './browsers'; +export { BROWSER_ENGINES, DRIVEN_ENGINES } from './browsers'; export type { BrowsersApi } from './browsersApi'; export type { ConsoleEntry, ConsoleFrame, ConsoleLevel, ConsoleLocation, ConsoleProperty, ConsoleSource, ConsoleValue, ConsoleValueKind } from './console'; export { CONSOLE_LEVELS } from './console'; diff --git a/test/e2e/browsers.e2e.test.ts b/test/e2e/browsers.e2e.test.ts index 8480709..0dd0643 100644 --- a/test/e2e/browsers.e2e.test.ts +++ b/test/e2e/browsers.e2e.test.ts @@ -104,7 +104,7 @@ describe.skipIf(!built || process.platform !== 'linux')('Other browsers', () => await rm(openedFile, { force: true }); await win.keyboard.press('Control+K'); await win.keyboard.type('Open in Fake'); - await win.getByRole('option', { name: /Open in Fake Chromium/ }).click(); + await win.getByRole('option', { name: 'Open in Fake Chromium', exact: true }).click(); await expect.poll(() => opened(openedFile), { timeout: 10_000 }).toBe(`${site.url}/`); }); diff --git a/test/e2e/driven-firefox.e2e.test.ts b/test/e2e/driven-firefox.e2e.test.ts new file mode 100644 index 0000000..c070a06 --- /dev/null +++ b/test/e2e/driven-firefox.e2e.test.ts @@ -0,0 +1,119 @@ +/** + * Firefox driven with the workspace's changes, in the built app on Linux: Firefox installed as a launcher is offered + * "with your changes" in the browser menu; the page opens there served the workspace's override, its tab is listed, + * and the page is captured here and there at once, as a group. Runs where Firefox is found: `FIREFOX_PATH`, or + * `firefox` on the PATH. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { DrivenBrowser, Shot } from '../../src/shared/types'; +import { killMatching } from '../helpers/killMatching'; + +const root = resolve(__dirname, '../..'); +const built = existsSync(join(root, 'out/main/index.js')); +const firefox = (() => { + if (process.env.FIREFOX_PATH) return existsSync(process.env.FIREFOX_PATH) ? process.env.FIREFOX_PATH : null; + try { + return execFileSync('which', ['firefox'], { encoding: 'utf8' }).trim() || null; + } catch { + return null; + } +})(); +// Chromium's sandbox can't start as root (containers). +const sandboxArgs = process.getuid?.() === 0 ? ['--no-sandbox'] : []; + +const EDITOR_URL = /\/renderer\/index\.html$/; +const BROWSER_ID = 'desktop:test-firefox.desktop'; + +/** Polls until `fn` returns a truthy value (usable outside tests, unlike expect.poll). */ +async function waitFor(fn: () => T | undefined | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!built || !firefox || process.platform !== 'linux')('Firefox with your changes', () => { + let server: Server; + let origin: string; + let dir: string; + let app: ElectronApplication; + let win: Page; + + type Api = { consoleEditor: { listDriven(): Promise; listShots(): Promise; createOverride(input: object): Promise } }; + const driven = () => win.evaluate(() => (window as unknown as Api).consoleEditor.listDriven()); + const shots = () => win.evaluate(() => (window as unknown as Api).consoleEditor.listShots()); + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const files: Record = { + '/page.html': ['text/html', 'upstream'], + '/app.js': ['text/javascript', "document.title = 'upstream';"], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + dir = await mkdtemp(join(tmpdir(), 'console-editor-e2e-firefox-')); + mkdirSync(join(dir, 'data/applications'), { recursive: true }); + mkdirSync(join(dir, 'system'), { recursive: true }); + // Headless, so the test needs no screen of its own for it. + writeFileSync(join(dir, 'data/applications/test-firefox.desktop'), `[Desktop Entry]\nType=Application\nName=Firefox\nExec=${firefox} --headless %u\nCategories=Network;WebBrowser;\n`); + + app = await electron.launch({ + args: [...sandboxArgs, root], + cwd: root, + env: { ...process.env, CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, + }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + const bar = win.getByTestId('address-bar'); + await bar.fill(`${origin}/page.html`); + await bar.press('Enter'); + await waitFor(() => app.evaluate(({ webContents }, o) => webContents.getAllWebContents().some((wc) => wc.getURL() === `${o}/page.html` && !wc.isLoading()), origin)); + await win.evaluate((o) => (window as unknown as Api).consoleEditor.createOverride({ kind: 'Script', sourceUrl: `${o}/app.js`, content: "document.title = 'overridden';", originalHash: null }), origin); + }); + + afterAll(async () => { + // Firefox outlives the app by design: quit it first (Playwright waits for everything the app started to be gone). + if (dir) killMatching(join(dir, 'user-data/browsers')); + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(dir, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('opens the page in Firefox with your changes, and lists its tab', async () => { + await win.getByTestId('browser-menu-button').click(); + const row = win.locator(`[data-testid="browser-row"][data-browser-id="${BROWSER_ID}"]`); + await row.getByTestId('browser-open-with-changes').click(); + await expect.poll(async () => (await driven())[0]?.tabs.map((t) => t.title), { timeout: 30_000 }).toEqual(['overridden']); + expect((await driven())[0]).toMatchObject({ id: BROWSER_ID, name: 'Firefox', version: expect.stringMatching(/^\d+\./) }); + await win.getByTestId('browser-menu-button').click(); + await expect.poll(() => win.getByTestId('driven-tab').innerText()).toContain('overridden'); + await win.keyboard.press('Escape'); + }); + + it('captures the page here and in Firefox at once, as a group', async () => { + await expect.poll(() => win.getByTestId('browser-menu').count()).toBe(0); + await win.getByTestId('shots-menu-button').click(); + await win.getByTestId('shots-capture').click(); + await win.getByRole('menuitem', { name: 'In every browser' }).click(); + await expect.poll(async () => (await shots()).filter((s) => s.group).length, { timeout: 40_000 }).toBe(2); + const [other, own] = (await shots()).filter((s) => s.group); + expect(own).toMatchObject({ area: 'page', browser: { id: 'app' } }); + expect(other).toMatchObject({ area: 'page', pageUrl: `${origin}/page.html`, group: own.group, browser: { id: BROWSER_ID, name: 'Firefox' }, viewport: own.viewport, scale: own.scale }); + }); +}); diff --git a/test/e2e/driven.e2e.test.ts b/test/e2e/driven.e2e.test.ts index 31be981..9d56a46 100644 --- a/test/e2e/driven.e2e.test.ts +++ b/test/e2e/driven.e2e.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { chromium, _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { readActivePort } from '../../src/main/browsers/driven/readActivePort'; +import { readActivePort } from '../../src/main/browsers/driven/chromium/readActivePort'; import { CdpConnection } from '../../src/main/engine/websocketTransport'; import type { DrivenBrowser, Shot } from '../../src/shared/types'; diff --git a/test/helpers/killMatching.ts b/test/helpers/killMatching.ts new file mode 100644 index 0000000..12a47a2 --- /dev/null +++ b/test/helpers/killMatching.ts @@ -0,0 +1,19 @@ +import { readdirSync, readFileSync } from 'node:fs'; + +/** Stops every process whose command line holds `fragment` (Linux: read from /proc), this one aside. */ +export function killMatching(fragment: string): void { + for (const pid of readdirSync('/proc').filter((name) => /^\d+$/.test(name) && Number(name) !== process.pid)) { + let command = ''; + try { + command = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + } catch { + continue; + } + if (!command.includes(fragment)) continue; + try { + process.kill(Number(pid)); + } catch { + // Gone already. + } + } +} diff --git a/test/integration/driven.chromium.test.ts b/test/integration/driven.chromium.test.ts index 9d0a2c7..06933e8 100644 --- a/test/integration/driven.chromium.test.ts +++ b/test/integration/driven.chromium.test.ts @@ -13,7 +13,7 @@ import { join } from 'node:path'; import { chromium } from 'playwright-core'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { DrivenBrowsers } from '../../src/main/browsers'; -import { readActivePort } from '../../src/main/browsers/driven/readActivePort'; +import { readActivePort } from '../../src/main/browsers/driven/chromium/readActivePort'; import type { InterceptionSources } from '../../src/main/browsers/driven/types'; import type { FoundBrowser } from '../../src/main/browsers'; import { CdpConnection } from '../../src/main/engine/websocketTransport'; @@ -139,9 +139,9 @@ describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes expect(tabs()).toHaveLength(3); }); - it('refuses a browser other than Chromium, and an address not on the web', async () => { - const other = new DrivenBrowsers({ registry: { get: async () => ({ ...browser, name: 'Firefox', engine: 'gecko' }) }, sources, userData, send: () => undefined }); - await expect(other.open(browser.id, `${origin}/page.html`)).rejects.toThrow("Firefox can't be served your changes"); + it('refuses a browser it can\'t drive (Safari), and an address not on the web', async () => { + const other = new DrivenBrowsers({ registry: { get: async () => ({ ...browser, name: 'Safari', engine: 'webkit' }) }, sources, userData, send: () => undefined }); + await expect(other.open(browser.id, `${origin}/page.html`)).rejects.toThrow("Safari can't be served your changes"); await expect(driven.open(browser.id, 'file:///etc/passwd')).rejects.toThrow('Only http(s) pages'); }); diff --git a/test/integration/driven.firefox.test.ts b/test/integration/driven.firefox.test.ts new file mode 100644 index 0000000..dacf531 --- /dev/null +++ b/test/integration/driven.firefox.test.ts @@ -0,0 +1,138 @@ +/** + * Firefox driven with the workspace's changes over WebDriver BiDi: launched with a profile of the app's own, a script + * override served, a header rule and a block rule applied, a cross-origin request an override answers readable (its + * preflight answered), changes served and the tab reloaded, a tab captured (and at a viewport given), the browser let + * go of and reached again, and forgotten once it is quit. Runs where Firefox is found: `FIREFOX_PATH`, or `firefox` on + * the PATH. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DrivenBrowsers, type FoundBrowser } from '../../src/main/browsers'; +import { readBidiPort } from '../../src/main/browsers/driven/firefox/readBidiPort'; +import type { InterceptionSources } from '../../src/main/browsers/driven/types'; +import { BidiConnection } from '../../src/main/engine/bidi'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type AppEvent, type DrivenTab, type Override, type Rule, type Settings } from '../../src/shared/types'; +import { decodePng } from '../helpers/decodePng'; +import { killMatching } from '../helpers/killMatching'; + +const firefox = (() => { + if (process.env.FIREFOX_PATH) return existsSync(process.env.FIREFOX_PATH) ? process.env.FIREFOX_PATH : null; + try { + return execFileSync('which', ['firefox'], { encoding: 'utf8' }).trim() || null; + } catch { + return null; + } +})(); + +async function waitFor(fn: () => T | undefined | false | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 200)); + } +} + +describe.skipIf(!firefox)('Firefox driven with your changes', () => { + let server: Server; + let origin: string; + let other: string; + let userData: string; + let driven: DrivenBrowsers; + const events: AppEvent[] = []; + const overrides: Override[] = []; + const rules: Rule[] = []; + const settings: Settings = { ...DEFAULT_SETTINGS, autoReloadOnSave: true }; + const sources = { store: { list: () => overrides, base: async () => '' }, rules: { list: () => rules }, settings: { get: () => settings } } as unknown as InterceptionSources; + const browser: FoundBrowser = { id: 'desktop:firefox.desktop', name: 'Firefox', engine: 'gecko', command: [firefox ?? 'firefox', '--headless'], urlAt: 2, iconFile: null, app: null, program: firefox, added: false }; + const profile = () => join(userData, 'browsers', 'desktop_firefox.desktop'); + const tabs = async (): Promise => (await driven.read())[0]?.tabs ?? []; + const now = Date.now(); + const override = (sourceUrl: string, kind: Override['kind'], content: string, extra: Partial = {}): Override => ({ id: `o${overrides.length}`, kind, sourceUrl, match: defaultMatcherFor(sourceUrl), content, enabled: true, originalHash: null, createdAt: now, updatedAt: now, ...extra }); + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const script = `Promise.all([fetch('/api').then((r) => r.headers.get('x-rule')), fetch('/blocked.js').then(() => 'loaded', () => 'blocked'), fetch('${other}/data', { method: 'PUT' }).then((r) => r.text(), () => 'refused')]).then((all) => { document.title = [window.appValue, ...all].join(' '); });`; + const files: Record = { + '/page.html': ['text/html', `upstream`], + '/app.js': ['text/javascript', "window.appValue = 'upstream';"], + '/api': ['application/json', '{}'], + '/blocked.js': ['text/javascript', ''], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + origin = `http://127.0.0.1:${port}`; + // Another origin (a CORS request), whose server never answers `/data`: only the override does. + other = `http://localhost:${port}`; + userData = await mkdtemp(join(tmpdir(), 'console-editor-firefox-')); + overrides.push(override(`${origin}/app.js`, 'Script', "window.appValue = 'overridden';"), override(`${other}/data`, 'Fetch', 'from-override', { request: { method: 'PUT', operation: '' }, response: { status: 200, delayMs: 0, headers: [], send: false, patch: false } })); + const rule = { enabled: true, resourceTypes: [], createdAt: now, updatedAt: now }; + rules.push({ ...rule, id: 'r1', action: 'headers', match: defaultMatcherFor(`${origin}/api`), headers: [{ operation: 'set', name: 'x-rule', value: 'yes' }] }, { ...rule, id: 'r2', action: 'block', match: defaultMatcherFor(`${origin}/blocked.js`) }); + driven = new DrivenBrowsers({ registry: { get: async () => browser }, sources, userData, send: (event) => events.push(event) }); + }); + + afterAll(async () => { + driven?.dispose(); + // Firefox outlives the app by design: quit it through its own port. + const address = userData ? await readBidiPort(profile()) : null; + const connection = address ? await BidiConnection.open(address).catch(() => null) : null; + await connection?.send('session.new', { capabilities: {} }).catch(() => undefined); + await connection?.send('browser.close').catch(() => undefined); + connection?.close(); + await new Promise((r) => server?.close(r)); + await rm(userData, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('launches it with a profile of its own, serving the overrides and applying the rules', async () => { + await driven.open(browser.id, `${origin}/page.html`); + await waitFor(async () => (await tabs()).some((t) => t.title === 'overridden yes blocked from-override')); + expect(driven.list()).toEqual([{ id: browser.id, name: 'Firefox', version: expect.stringMatching(/^\d+\./), tabs: [expect.objectContaining({ url: `${origin}/page.html` })] }]); + expect(existsSync(join(profile(), 'WebDriverBiDiServer.json'))).toBe(true); + expect(readFileSync(join(profile(), 'user.js'), 'utf8')).toContain('browser.shell.checkDefaultBrowser'); + }); + + it('serves overrides as they change, reloading its tabs when the settings say so', async () => { + overrides[0] = { ...overrides[0], content: "window.appValue = 'changed';", updatedAt: Date.now() }; + driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); + await waitFor(async () => (await tabs()).some((t) => t.title.startsWith('changed yes blocked'))); + }); + + it('captures a tab, and the whole page at a viewport given', async () => { + const [tab] = await tabs(); + const { image, browser: taken } = await driven.capture(browser.id, tab.id, 'viewport'); + expect(taken).toMatchObject({ id: browser.id, name: 'Firefox' }); + expect(decodePng(image.bytes).at(5, 5)).toEqual([255, 0, 0, 255]); + const [capture] = await driven.captureAt(`${origin}/page.html`, { width: 400, height: 300, scale: 2 }); + if ('reason' in capture) throw new Error(capture.reason); + expect(capture.image).toMatchObject({ width: 800, height: 600, scale: 2, viewport: { width: 400, height: 300 } }); + expect(await tabs()).toHaveLength(1); + }); + + it('lets go of it (it stays open), and reaches it again rather than launching another', async () => { + const port = readFileSync(join(profile(), 'WebDriverBiDiServer.json'), 'utf8'); + driven.stop(browser.id); + expect(driven.list()).toEqual([]); + await driven.open(browser.id, `${origin}/page.html?again`); + expect(readFileSync(join(profile(), 'WebDriverBiDiServer.json'), 'utf8')).toBe(port); + await waitFor(async () => (await tabs()).some((t) => t.url.endsWith('?again') && t.title.startsWith('changed'))); + }); + + it('forgets it once it is quit', async () => { + killMatching(profile()); + await waitFor(() => driven.list().length === 0); + expect(events.at(-1)).toEqual({ type: 'driven-browsers-changed', driven: [] }); + }); +}); diff --git a/test/unit/bidi.test.ts b/test/unit/bidi.test.ts new file mode 100644 index 0000000..8485de3 --- /dev/null +++ b/test/unit/bidi.test.ts @@ -0,0 +1,95 @@ +/** + * Driving Firefox over WebDriver BiDi, the parts that need no browser: the resource type each request is matched as, + * what rules read from a request, the head an override answers with before its request is sent, and the address + * Firefox writes in its profile. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { readBidiPort } from '../../src/main/browsers/driven/firefox/readBidiPort'; +import { requestOf } from '../../src/main/engine/bidi/requestOf'; +import { resourceTypeOf } from '../../src/main/engine/bidi/resourceTypeOf'; +import { answerContext, decideRequest } from '../../src/main/engine/answering'; +import { servedHead } from '../../src/main/engine/answering/servedHead'; +import type { BidiRequestData } from '../../src/main/engine/bidi/types'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type Override, type Rule } from '../../src/shared/types'; + +const tmp = mkdtempSync(join(tmpdir(), 'console-editor-bidi-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const data = (extra: Partial = {}): BidiRequestData => ({ request: '1', url: 'https://shop.test/app.js', method: 'GET', headers: [], ...extra }); +const header = (name: string, value: string) => ({ name, value: { type: 'string' as const, value } }); + +describe('BiDi requests', () => { + it('are matched as the resource type the engine knows: by destination, else by initiator type', () => { + const types = (['document', 'iframe', 'style', 'script', 'image', 'font', 'video', 'worker'] as const).map((destination) => resourceTypeOf(data({ destination }))); + expect(types).toEqual(['Document', 'Document', 'Stylesheet', 'Script', 'Image', 'Font', 'Media', 'Other']); + expect(resourceTypeOf(data({ destination: '', initiatorType: 'fetch' }))).toBe('Fetch'); + expect(resourceTypeOf(data({ destination: '', initiatorType: 'xmlhttprequest' }))).toBe('XHR'); + expect(resourceTypeOf(data({ destination: '', initiatorType: 'beacon' }))).toBe('Ping'); + expect(resourceTypeOf(data({ destination: '', initiatorType: null }))).toBe('Other'); + expect(resourceTypeOf(data({ destination: 'constructor' }))).toBe('Other'); + }); + + it('give rules their address, method and headers by lower-case name', () => { + expect(requestOf(data({ method: 'PUT', headers: [header('Origin', 'https://app.test'), header('X-Thing', '1')] }))).toEqual({ url: 'https://shop.test/app.js', method: 'PUT', headers: { origin: 'https://app.test', 'x-thing': '1' } }); + }); +}); + +describe('The head an override answers with before its request is sent', () => { + const now = Date.now(); + const override = (extra: Partial = {}): Override => ({ id: 'o1', kind: 'Script', sourceUrl: 'https://shop.test/app.js', match: defaultMatcherFor('https://shop.test/app.js'), content: '1', enabled: true, originalHash: null, createdAt: now, updatedAt: now, ...extra }); + const ctx = (rules: Rule[] = [], overrides: Override[] = []) => answerContext({ getOverrides: () => overrides, getRules: () => rules, getSettings: () => DEFAULT_SETTINGS }); + const names = (headers: { name: string; value: string }[]) => Object.fromEntries(headers.map((h) => [h.name.toLowerCase(), h.value])); + + it("has its kind's content type, and isn't kept in the cache", () => { + const head = servedHead(override(), requestOf(data()), 'Script', ctx()); + expect(head.status).toBe(200); + expect(names(head.headers)).toMatchObject({ 'content-type': expect.stringMatching(/javascript.*utf-8/i), 'cache-control': 'no-store' }); + }); + + it("is readable from another origin, and takes a response override's status and header changes, then the rules", () => { + const fetchOverride = override({ kind: 'Fetch', response: { status: 201, delayMs: 0, headers: [{ operation: 'set', name: 'x-own', value: 'yes' }], send: false, patch: false } }); + const rule: Rule = { id: 'r1', action: 'headers', match: defaultMatcherFor('https://shop.test/app.js'), resourceTypes: [], enabled: true, createdAt: now, updatedAt: now, headers: [{ operation: 'set', name: 'x-rule', value: 'on' }] }; + const head = servedHead(fetchOverride, requestOf(data({ headers: [header('Origin', 'https://app.test')] })), 'Fetch', ctx([rule])); + expect(head.status).toBe(201); + expect(names(head.headers)).toMatchObject({ 'x-own': 'yes', 'x-rule': 'on', 'access-control-allow-origin': 'https://app.test', 'access-control-allow-credentials': 'true' }); + }); +}); + +describe('Deciding what to do with a request before it is sent', () => { + const now = Date.now(); + const rule = (action: 'block' | 'cors', url: string): Rule => ({ id: `r-${action}`, action, match: defaultMatcherFor(url), resourceTypes: [], enabled: true, createdAt: now, updatedAt: now }); + const script: Override = { id: 'o1', kind: 'Script', sourceUrl: 'https://shop.test/app.js', match: defaultMatcherFor('https://shop.test/app.js'), content: 'patched()', enabled: true, originalHash: null, createdAt: now, updatedAt: now }; + const api: Override = { ...script, id: 'o2', kind: 'Fetch', sourceUrl: 'https://api.test/data', match: defaultMatcherFor('https://api.test/data'), content: '{}', request: { method: 'PUT', operation: '' }, response: { status: 200, delayMs: 250, headers: [], send: false, patch: false } }; + const decide = (url: string, type: string, extra: Partial = {}, rules: Rule[] = []) => decideRequest(answerContext({ getOverrides: () => [script, api], getRules: () => rules, getSettings: () => DEFAULT_SETTINGS }), requestOf(data({ url, ...extra })), type); + + it('fails one a block rule takes, before anything else', () => { + expect(decide('https://shop.test/app.js', 'Script', {}, [rule('block', 'https://shop.test/app.js')])).toEqual({ action: 'fail' }); + }); + + it("answers one an override takes, with its body and delay; a response override's preflight too", () => { + expect(decide('https://shop.test/app.js', 'Script')).toMatchObject({ action: 'answer', body: 'patched()', delayMs: 0, head: { status: 200 } }); + expect(decide('https://api.test/data', 'Fetch', { method: 'PUT' })).toMatchObject({ action: 'answer', body: '{}', delayMs: 250 }); + const preflight = decide('https://api.test/data', 'Other', { method: 'OPTIONS', headers: [header('Origin', 'https://app.test'), header('Access-Control-Request-Method', 'PUT')] }); + expect(preflight).toMatchObject({ action: 'answer', body: '', head: { status: 204 } }); + }); + + it("sends on the rest: another address, a kind the override doesn't answer, another method", () => { + expect(decide('https://shop.test/other.js', 'Script')).toEqual({ action: 'continue' }); + expect(decide('https://shop.test/app.js', 'Stylesheet')).toEqual({ action: 'continue' }); + expect(decide('https://api.test/data', 'Fetch', { method: 'GET' })).toEqual({ action: 'continue' }); + }); +}); + +describe("Firefox's BiDi address", () => { + it('is read from the file it writes in its profile, once written and whole', async () => { + expect(await readBidiPort(tmp)).toBeNull(); + writeFileSync(join(tmp, 'WebDriverBiDiServer.json'), '{ "ws_host": "127.0.0.1", "ws_port": 9222 }'); + expect(await readBidiPort(tmp)).toBe('ws://127.0.0.1:9222/session'); + writeFileSync(join(tmp, 'WebDriverBiDiServer.json'), '{ "ws_host": "127.0.0.1"'); + expect(await readBidiPort(tmp)).toBeNull(); + }); +}); diff --git a/test/unit/browsers.test.ts b/test/unit/browsers.test.ts index a6df4f5..e67f577 100644 --- a/test/unit/browsers.test.ts +++ b/test/unit/browsers.test.ts @@ -32,9 +32,9 @@ const { launchEnv } = await import('../../src/main/browsers/launchEnv'); const { BrowserRegistry } = await import('../../src/main/browsers'); const { driveCommand } = await import('../../src/main/browsers/driven/driveCommand'); const { profileDir } = await import('../../src/main/browsers/driven/profileDir'); -const { readActivePort } = await import('../../src/main/browsers/driven/readActivePort'); +const { readActivePort } = await import('../../src/main/browsers/driven/chromium/readActivePort'); const { DrivenTabs } = await import('../../src/main/browsers/driven/DrivenTabs'); -type DrivenTabState = import('../../src/main/browsers/driven/types').DrivenTabState; +type DrivenTabState = import('../../src/main/browsers/driven/chromium/types').DrivenTabState; const { BrowserStore } = await import('../../src/main/store/BrowserStore'); type FoundBrowser = import('../../src/main/browsers').FoundBrowser; @@ -323,20 +323,22 @@ describe('Driving a Chromium browser', () => { expect(await readActivePort(dir)).toBeNull(); }); - it('keeps its tabs: by target and session, their changes, a blank one to reuse, and the one a new target becomes', async () => { - const tabs = new DrivenTabs(); + it('keeps its tabs: their changes, the one showing an address, a blank one to reuse, and the one a new target becomes', async () => { + const tabs = new DrivenTabs(); const tab = (id: string, url: string) => ({ info: { id, title: '', url }, sessionId: `s-${id}` }) as DrivenTabState; tabs.add(tab('a', 'about:blank')); expect(tabs.blank()?.info.id).toBe('a'); - expect(tabs.update({ targetId: 'a', type: 'page', url: 'https://shop.test/', title: 'Shop' })).toBe(true); - expect(tabs.update({ targetId: 'a', type: 'page', url: 'https://shop.test/', title: 'Shop' })).toBe(false); - expect(tabs.update({ targetId: 'gone', type: 'page', url: 'x', title: 'y' })).toBe(false); + expect(tabs.update('a', { url: 'https://shop.test/', title: 'Shop' })).toBe(true); + expect(tabs.update('a', { url: 'https://shop.test/', title: 'Shop' })).toBe(false); + expect(tabs.update('a', { title: 'Shop' })).toBe(false); + expect(tabs.update('gone', { url: 'x', title: 'y' })).toBe(false); expect(tabs.blank()).toBeUndefined(); const arriving = tabs.arrival('b'); tabs.add(tab('b', 'about:blank')); expect((await arriving).info.id).toBe('b'); expect(tabs.list()).toEqual([{ id: 'a', title: 'Shop', url: 'https://shop.test/' }, { id: 'b', title: '', url: 'about:blank' }]); - expect(tabs.remove('s-a')?.info.id).toBe('a'); + expect(tabs.showing('https://shop.test/')?.info.id).toBe('a'); + expect(tabs.remove('a')?.info.id).toBe('a'); expect(() => tabs.get('a')).toThrow('That tab is closed'); expect(tabs.clear().map((t) => t.info.id)).toEqual(['b']); }); From c3546b1d88f1584241a2e347c30487114a1943ad Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 22:33:58 +0000 Subject: [PATCH 09/19] List your everyday Firefox's tabs in the browser menu, to open one here With Firefox installed, the browser menu offers "Your Firefox tabs". Once asked, it lists the tabs open in each Firefox profile, the install's default first, and choosing one loads its address in the app. The search narrows them like the other tabs. The profiles come from profiles.ini, in Firefox's own folder on each system (Linux's Snap and Flatpak included). The tabs are read from the session file Firefox keeps up to date (recovery.jsonlz4, an LZ4 block behind a small header, decoded here), and only http(s) pages are listed. That file is read only after the user asks, and nothing of it leaves the app. --- src/main/browsers/everyday/constants.ts | 21 +++++ src/main/browsers/everyday/index.ts | 1 + .../browsers/everyday/listEverydayTabs.ts | 27 ++++++ src/main/browsers/everyday/lz4Block.ts | 36 ++++++++ src/main/browsers/everyday/parseIniGroups.ts | 13 +++ src/main/browsers/everyday/profilesOf.ts | 20 ++++ src/main/browsers/everyday/readMozLz4.ts | 14 +++ src/main/browsers/everyday/sessionTabsOf.ts | 27 ++++++ src/main/ipc/registerBrowserIpc.ts | 2 + src/preload/browsersApi.ts | 1 + .../src/entities/browser/model/store/types.ts | 5 +- .../browser/model/store/useBrowserStore.ts | 2 + .../browser/open-in-browser/model/index.ts | 1 + .../open-in-browser/model/loadBrowsers.ts | 9 +- .../open-in-browser/model/loadEverydayTabs.ts | 12 +++ .../open-in-browser/model/matchesTab.ts | 4 +- .../open-in-browser/model/openTabHere.ts | 4 +- .../open-in-browser/ui/BrowserMenu.tsx | 2 + .../open-in-browser/ui/DrivenTabRow.tsx | 4 +- .../open-in-browser/ui/EverydaySection.tsx | 60 ++++++++++++ .../browser/open-in-browser/ui/TabText.tsx | 11 +++ src/shared/ipcChannels.ts | 1 + src/shared/types/browsers.ts | 15 +++ src/shared/types/browsersApi.ts | 4 +- src/shared/types/index.ts | 2 +- test/e2e/everydayTabs.e2e.test.ts | 86 ++++++++++++++++++ test/fixtures/firefox/recovery.jsonlz4 | Bin 0 -> 1297 bytes test/helpers/mozLz4.ts | 18 ++++ test/unit/everydayTabs.test.ts | 75 +++++++++++++++ 29 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 src/main/browsers/everyday/constants.ts create mode 100644 src/main/browsers/everyday/index.ts create mode 100644 src/main/browsers/everyday/listEverydayTabs.ts create mode 100644 src/main/browsers/everyday/lz4Block.ts create mode 100644 src/main/browsers/everyday/parseIniGroups.ts create mode 100644 src/main/browsers/everyday/profilesOf.ts create mode 100644 src/main/browsers/everyday/readMozLz4.ts create mode 100644 src/main/browsers/everyday/sessionTabsOf.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/model/loadEverydayTabs.ts create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/EverydaySection.tsx create mode 100644 src/renderer/src/features/browser/open-in-browser/ui/TabText.tsx create mode 100644 test/e2e/everydayTabs.e2e.test.ts create mode 100644 test/fixtures/firefox/recovery.jsonlz4 create mode 100644 test/helpers/mozLz4.ts create mode 100644 test/unit/everydayTabs.test.ts diff --git a/src/main/browsers/everyday/constants.ts b/src/main/browsers/everyday/constants.ts new file mode 100644 index 0000000..316d43f --- /dev/null +++ b/src/main/browsers/everyday/constants.ts @@ -0,0 +1,21 @@ +/** A Firefox session file's header: its magic, then the JSON's length (32-bit, little-endian), then an LZ4 block. */ +export const MOZ_LZ4 = { magic: 'mozLz40\0', sizeAt: 8, dataAt: 12 } as const; + +/** An LZ4 sequence's token: the literal length in its high nibble, the match length (less its minimum) in its low one. */ +export const LZ4 = { lengthBits: 4, nibble: 15, more: 255, minMatch: 4, offsetBytes: 2 } as const; + +/** The session file Firefox keeps up to date as it runs, in its profile. */ +export const SESSION_FILE = ['sessionstore-backups', 'recovery.jsonlz4'] as const; + +/** Firefox's list of profiles, in its data folder. */ +export const PROFILES_INI = 'profiles.ini'; + +/** Where Firefox keeps its data on each system, from the home folder; Linux's Snap and Flatpak keep theirs apart. */ +export const FIREFOX_DATA_DIRS: Partial> = { + linux: ['.mozilla/firefox', 'snap/firefox/common/.mozilla/firefox', '.var/app/org.mozilla.firefox/.mozilla/firefox'], + darwin: ['Library/Application Support/Firefox'], + win32: ['AppData/Roaming/Mozilla/Firefox'], +}; + +/** profiles.ini: an install's default profile (`[Install…]` groups), and the profiles (`[Profile…]` groups). */ +export const PROFILES_KEYS = { installGroup: 'Install', profileGroup: 'Profile', installDefault: 'Default', path: 'Path', name: 'Name', relative: 'IsRelative', relativeYes: '1' } as const; diff --git a/src/main/browsers/everyday/index.ts b/src/main/browsers/everyday/index.ts new file mode 100644 index 0000000..2b1f931 --- /dev/null +++ b/src/main/browsers/everyday/index.ts @@ -0,0 +1 @@ +export { listEverydayTabs } from './listEverydayTabs'; diff --git a/src/main/browsers/everyday/listEverydayTabs.ts b/src/main/browsers/everyday/listEverydayTabs.ts new file mode 100644 index 0000000..1c790dc --- /dev/null +++ b/src/main/browsers/everyday/listEverydayTabs.ts @@ -0,0 +1,27 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { EverydayBrowser } from '../../../shared/types'; +import { FIREFOX_DATA_DIRS, PROFILES_INI, SESSION_FILE } from './constants'; +import { profilesOf } from './profilesOf'; +import { readMozLz4 } from './readMozLz4'; +import { sessionTabsOf } from './sessionTabsOf'; + +/** The browser name everyday Firefox tabs are listed under. */ +const FIREFOX = 'Firefox'; + +/** + * The tabs open in your everyday Firefox, profile by profile, as its session file has them (it is kept up to date while + * Firefox runs, and holds the last session once it has quit). Read when asked, here only; profiles with none are left out. + */ +export async function listEverydayTabs(home = homedir(), platform = process.platform): Promise { + const found: EverydayBrowser[] = []; + for (const dataDir of (FIREFOX_DATA_DIRS[platform] ?? []).map((dir) => join(home, dir))) { + const ini = await readFile(join(dataDir, PROFILES_INI), 'utf8').catch(() => ''); + for (const profile of profilesOf(dataDir, ini)) { + const tabs = sessionTabsOf((await readMozLz4(join(profile.dir, ...SESSION_FILE))) ?? ''); + if (tabs.length) found.push({ id: profile.dir, name: FIREFOX, profile: profile.name, tabs }); + } + } + return found; +} diff --git a/src/main/browsers/everyday/lz4Block.ts b/src/main/browsers/everyday/lz4Block.ts new file mode 100644 index 0000000..431c284 --- /dev/null +++ b/src/main/browsers/everyday/lz4Block.ts @@ -0,0 +1,36 @@ +import { LZ4 } from './constants'; + +/** + * Decompresses an LZ4 block into at most `size` bytes: sequences of literals copied as they are, each but the last + * followed by a match copied from `offset` bytes back (overlapping copies repeat, byte by byte). + */ +export function lz4Block(input: Uint8Array, size: number): Buffer { + const out = Buffer.alloc(size); + let at = 0; + let written = 0; + // A length of 15 goes on in the bytes after it, each added, until one isn't 255. + const length = (start: number): number => { + if (start !== LZ4.nibble) return start; + let total = start; + let byte: number; + do { + byte = input[at++]; + total += byte; + } while (byte === LZ4.more && at < input.length); + return total; + }; + while (at < input.length) { + const token = input[at++]; + const literals = length(token >> LZ4.lengthBits); + out.set(input.subarray(at, at + literals), written); + at += literals; + written += literals; + if (at >= input.length) break; + const offset = input[at] | (input[at + 1] << 8); + at += LZ4.offsetBytes; + const matched = length(token & LZ4.nibble) + LZ4.minMatch; + if (!offset || offset > written || written + matched > size) throw new Error('Not an LZ4 block'); + for (let i = 0; i < matched; i++, written++) out[written] = out[written - offset]; + } + return out.subarray(0, written); +} diff --git a/src/main/browsers/everyday/parseIniGroups.ts b/src/main/browsers/everyday/parseIniGroups.ts new file mode 100644 index 0000000..eb72143 --- /dev/null +++ b/src/main/browsers/everyday/parseIniGroups.ts @@ -0,0 +1,13 @@ +/** The groups of an INI file (`[Name]` then `key=value` lines), in order, with their keys; comments and blank lines skipped. */ +export function parseIniGroups(text: string): Array<{ name: string; keys: Record }> { + const groups: Array<{ name: string; keys: Record }> = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) continue; + const group = /^\[(.+)\]$/.exec(line); + if (group) groups.push({ name: group[1], keys: {} }); + const eq = line.indexOf('='); + if (!group && eq > 0 && groups.length) groups[groups.length - 1].keys[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + } + return groups; +} diff --git a/src/main/browsers/everyday/profilesOf.ts b/src/main/browsers/everyday/profilesOf.ts new file mode 100644 index 0000000..90f4484 --- /dev/null +++ b/src/main/browsers/everyday/profilesOf.ts @@ -0,0 +1,20 @@ +import { isAbsolute, join } from 'node:path'; +import { PROFILES_KEYS } from './constants'; +import { parseIniGroups } from './parseIniGroups'; + +/** A Firefox profile: its name, and its folder. */ +export interface FirefoxProfile { + name: string; + dir: string; +} + +/** The profiles a `profiles.ini` in `dataDir` lists, each once: the installs' defaults first (the ones Firefox opens). */ +export function profilesOf(dataDir: string, ini: string): FirefoxProfile[] { + const groups = parseIniGroups(ini); + const at = (path: string, relative: boolean) => (relative && !isAbsolute(path) ? join(dataDir, path) : path); + const defaults = groups.filter((g) => g.name.startsWith(PROFILES_KEYS.installGroup) && g.keys[PROFILES_KEYS.installDefault]).map((g) => at(g.keys[PROFILES_KEYS.installDefault], true)); + const profiles = groups + .filter((g) => g.name.startsWith(PROFILES_KEYS.profileGroup) && g.keys[PROFILES_KEYS.path]) + .map((g) => ({ name: g.keys[PROFILES_KEYS.name] ?? g.keys[PROFILES_KEYS.path], dir: at(g.keys[PROFILES_KEYS.path], g.keys[PROFILES_KEYS.relative] === PROFILES_KEYS.relativeYes) })); + return profiles.sort((a, b) => Number(defaults.includes(b.dir)) - Number(defaults.includes(a.dir))); +} diff --git a/src/main/browsers/everyday/readMozLz4.ts b/src/main/browsers/everyday/readMozLz4.ts new file mode 100644 index 0000000..288c2b8 --- /dev/null +++ b/src/main/browsers/everyday/readMozLz4.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; +import { MOZ_LZ4 } from './constants'; +import { lz4Block } from './lz4Block'; + +/** The text of a Firefox `.jsonlz4` file (its session, its search engines…); null when it isn't one, or can't be read. */ +export async function readMozLz4(path: string): Promise { + const bytes = await readFile(path).catch(() => null); + if (!bytes || bytes.length < MOZ_LZ4.dataAt || bytes.toString('latin1', 0, MOZ_LZ4.sizeAt) !== MOZ_LZ4.magic) return null; + try { + return lz4Block(bytes.subarray(MOZ_LZ4.dataAt), bytes.readUInt32LE(MOZ_LZ4.sizeAt)).toString('utf8'); + } catch { + return null; + } +} diff --git a/src/main/browsers/everyday/sessionTabsOf.ts b/src/main/browsers/everyday/sessionTabsOf.ts new file mode 100644 index 0000000..9aaa59a --- /dev/null +++ b/src/main/browsers/everyday/sessionTabsOf.ts @@ -0,0 +1,27 @@ +import type { EverydayTab } from '../../../shared/types'; +import { HTTP_URL } from '../../constants'; + +/** What a Firefox session says of a tab, as far as its current page goes. */ +interface SessionTab { + index?: number; + entries?: Array<{ url?: unknown; title?: unknown }>; +} + +/** The tabs a Firefox session (`recovery.jsonlz4`'s JSON) has open, window by window: the page each shows, if on the web. */ +export function sessionTabsOf(json: string): EverydayTab[] { + let session: { windows?: Array<{ tabs?: SessionTab[] }> }; + try { + session = JSON.parse(json) as typeof session; + } catch { + return []; + } + return (session.windows ?? []).flatMap((w) => + (w.tabs ?? []).flatMap((tab) => { + const entries = tab.entries ?? []; + const entry = entries[(tab.index ?? entries.length) - 1]; + const url = typeof entry?.url === 'string' ? entry.url : ''; + if (!HTTP_URL.test(url)) return []; + return [{ url, title: typeof entry?.title === 'string' && entry.title ? entry.title : url }]; + }), + ); +} diff --git a/src/main/ipc/registerBrowserIpc.ts b/src/main/ipc/registerBrowserIpc.ts index 65af4dc..f265037 100644 --- a/src/main/ipc/registerBrowserIpc.ts +++ b/src/main/ipc/registerBrowserIpc.ts @@ -1,6 +1,7 @@ import { dialog, type BrowserWindow, type OpenDialogOptions } from 'electron'; import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { BrowserRegistry, DrivenBrowsers } from '../browsers'; +import { listEverydayTabs } from '../browsers/everyday'; import { assertString } from './assertString'; import type { IpcHandle } from './types'; @@ -34,6 +35,7 @@ export function registerBrowserIpc(handle: IpcHandle, handlePage: IpcHandle, { w return driven.open(id, url); }); handlePage(IPC_CHANNEL.listDriven, () => driven.read()); + handlePage(IPC_CHANNEL.listEverydayTabs, () => listEverydayTabs()); handlePage(IPC_CHANNEL.activateTab, (browserId: unknown, tabId: unknown) => { assertString(browserId, 'browserId'); assertString(tabId, 'tabId'); diff --git a/src/preload/browsersApi.ts b/src/preload/browsersApi.ts index 5381648..b7c18ec 100644 --- a/src/preload/browsersApi.ts +++ b/src/preload/browsersApi.ts @@ -13,4 +13,5 @@ export const browsersApi: BrowsersApi = { listDriven: () => ipcRenderer.invoke(IPC_CHANNEL.listDriven), activateTab: (browserId, tabId) => ipcRenderer.invoke(IPC_CHANNEL.activateTab, browserId, tabId), stopDriving: (browserId) => ipcRenderer.invoke(IPC_CHANNEL.stopDriving, browserId), + listEverydayTabs: () => ipcRenderer.invoke(IPC_CHANNEL.listEverydayTabs), }; diff --git a/src/renderer/src/entities/browser/model/store/types.ts b/src/renderer/src/entities/browser/model/store/types.ts index 41c499d..5e4799b 100644 --- a/src/renderer/src/entities/browser/model/store/types.ts +++ b/src/renderer/src/entities/browser/model/store/types.ts @@ -1,4 +1,4 @@ -import type { BrowserInfo, DrivenBrowser } from '@common/types'; +import type { BrowserInfo, DrivenBrowser, EverydayBrowser } from '@common/types'; export interface BrowserStore { /** The browsers on this computer and the ones the user added, hidden ones too. */ @@ -7,7 +7,10 @@ export interface BrowserStore { loaded: boolean; /** The Chromium browsers the app drives with the workspace's changes, with their tabs. */ driven: DrivenBrowser[]; + /** Your everyday Firefox's tabs, once asked for (null until then: its session is only read when asked). */ + everyday: EverydayBrowser[] | null; setAll(browsers: BrowserInfo[]): void; setDriven(driven: DrivenBrowser[]): void; + setEveryday(everyday: EverydayBrowser[]): void; } diff --git a/src/renderer/src/entities/browser/model/store/useBrowserStore.ts b/src/renderer/src/entities/browser/model/store/useBrowserStore.ts index 2eb565d..1b531dc 100644 --- a/src/renderer/src/entities/browser/model/store/useBrowserStore.ts +++ b/src/renderer/src/entities/browser/model/store/useBrowserStore.ts @@ -6,6 +6,8 @@ export const useBrowserStore = create()((set) => ({ browsers: [], loaded: false, driven: [], + everyday: null, setAll: (browsers) => set({ browsers, loaded: true }), setDriven: (driven) => set({ driven }), + setEveryday: (everyday) => set({ everyday }), })); diff --git a/src/renderer/src/features/browser/open-in-browser/model/index.ts b/src/renderer/src/features/browser/open-in-browser/model/index.ts index 2949832..d1a155f 100644 --- a/src/renderer/src/features/browser/open-in-browser/model/index.ts +++ b/src/renderer/src/features/browser/open-in-browser/model/index.ts @@ -1,6 +1,7 @@ export { activateTab } from './activateTab'; export { captureTab } from './captureTab'; export { loadBrowsers } from './loadBrowsers'; +export { loadEverydayTabs } from './loadEverydayTabs'; export { matchesBrowser } from './matchesBrowser'; export { matchesTab } from './matchesTab'; export { openInBrowser } from './openInBrowser'; diff --git a/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts b/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts index 0de5e85..3ce7220 100644 --- a/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts +++ b/src/renderer/src/features/browser/open-in-browser/model/loadBrowsers.ts @@ -1,13 +1,16 @@ import { api } from '@/shared/api'; import { useBrowserStore } from '@/entities/browser'; +import { loadEverydayTabs } from './loadEverydayTabs'; /** - * Asks for the browsers (the main process looks again when its list is a minute old) and the ones driven with the - * workspace's changes; a failure leaves the lists as they were, the browsers looked for. + * Asks for the browsers (the main process looks again when its list is a minute old), the ones driven with the + * workspace's changes, and your everyday Firefox's tabs once they were asked for; a failure leaves the lists as they + * were, the browsers looked for. */ export async function loadBrowsers(): Promise { - const { setAll, setDriven } = useBrowserStore.getState(); + const { setAll, setDriven, everyday } = useBrowserStore.getState(); const [browsers, driven] = await Promise.all([api.listBrowsers().catch(() => useBrowserStore.getState().browsers), api.listDriven().catch(() => useBrowserStore.getState().driven)]); setAll(browsers); setDriven(driven); + if (everyday) await loadEverydayTabs(); } diff --git a/src/renderer/src/features/browser/open-in-browser/model/loadEverydayTabs.ts b/src/renderer/src/features/browser/open-in-browser/model/loadEverydayTabs.ts new file mode 100644 index 0000000..7a96561 --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/model/loadEverydayTabs.ts @@ -0,0 +1,12 @@ +import { api, errorMessage } from '@/shared/api'; +import { toast } from '@/shared/ui/toast'; +import { useBrowserStore } from '@/entities/browser'; + +/** Reads your everyday Firefox's tabs (from its session file, in the main process), and keeps them for the menu. */ +export async function loadEverydayTabs(): Promise { + try { + useBrowserStore.getState().setEveryday(await api.listEverydayTabs()); + } catch (err) { + toast({ title: 'Could not read your Firefox tabs', description: errorMessage(err), tone: 'danger' }); + } +} diff --git a/src/renderer/src/features/browser/open-in-browser/model/matchesTab.ts b/src/renderer/src/features/browser/open-in-browser/model/matchesTab.ts index a7b84c5..e0dc49d 100644 --- a/src/renderer/src/features/browser/open-in-browser/model/matchesTab.ts +++ b/src/renderer/src/features/browser/open-in-browser/model/matchesTab.ts @@ -1,7 +1,7 @@ import type { DrivenTab } from '@common/types'; import { matchesWords } from '@/shared/lib'; -/** Whether a driven tab answers the menu's search: every word in its title or address. */ -export function matchesTab(tab: DrivenTab, query: string): boolean { +/** Whether a tab answers the menu's search: every word in its title or address. */ +export function matchesTab(tab: Pick, query: string): boolean { return matchesWords(`${tab.title} ${tab.url}`, query); } diff --git a/src/renderer/src/features/browser/open-in-browser/model/openTabHere.ts b/src/renderer/src/features/browser/open-in-browser/model/openTabHere.ts index be0a2b3..53908d9 100644 --- a/src/renderer/src/features/browser/open-in-browser/model/openTabHere.ts +++ b/src/renderer/src/features/browser/open-in-browser/model/openTabHere.ts @@ -2,8 +2,8 @@ import type { DrivenTab } from '@common/types'; import { api, errorMessage } from '@/shared/api'; import { toast } from '@/shared/ui/toast'; -/** Loads a driven tab's address in the app's own page. */ -export async function openTabHere(tab: DrivenTab): Promise { +/** Loads a tab's address (a driven browser's, or your everyday one's) in the app's own page. */ +export async function openTabHere(tab: Pick): Promise { try { await api.navigate(tab.url); } catch (err) { diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx index a3efb93..0e9e497 100644 --- a/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx @@ -12,6 +12,7 @@ import { usePageStore } from '@/entities/page'; import { matchesBrowser, openInBrowser, openWithChanges } from '../model'; import { BrowserRow } from './BrowserRow'; import { DrivenSection } from './DrivenSection'; +import { EverydaySection } from './EverydaySection'; export interface BrowserMenuProps { onClose(): void; @@ -67,6 +68,7 @@ export function BrowserMenu({ onClose, onShowSettings }: BrowserMenuProps) { ) : null}
+
); } diff --git a/src/renderer/src/features/browser/open-in-browser/ui/DrivenTabRow.tsx b/src/renderer/src/features/browser/open-in-browser/ui/DrivenTabRow.tsx index 4adf9d8..bc23963 100644 --- a/src/renderer/src/features/browser/open-in-browser/ui/DrivenTabRow.tsx +++ b/src/renderer/src/features/browser/open-in-browser/ui/DrivenTabRow.tsx @@ -3,6 +3,7 @@ import { icons } from '@/shared/config'; import { webAddress } from '@/shared/lib'; import { IconButton } from '@/shared/ui/icon-button'; import { activateTab, captureTab, openTabHere } from '../model'; +import { TabText } from './TabText'; export interface DrivenTabRowProps { browser: DrivenBrowser; @@ -23,8 +24,7 @@ export function DrivenTabRow({ browser, tab, onClose }: DrivenTabRowProps) { }} className="flex min-w-0 flex-1 flex-col rounded-lg px-2 py-1 text-left outline-none focus-visible:bg-hover" > - {tab.title || tab.url} - {tab.url} + s.everyday); + const hasFirefox = useBrowserStore((s) => s.browsers.some((b) => b.engine === 'gecko')); + if (!everyday) { + if (!hasFirefox) return null; + return ( + + ); + } + return ( +
+ {everyday.length ? null :

No Firefox tabs found.

} + {everyday.map((browser) => ( +
+ + {browser.name} · {browser.profile} + + {browser.tabs + // Keyed by where they are in the session (the same page can be open twice), before the search narrows them. + .map((tab, i) => ({ tab, key: `${i}:${tab.url}` })) + .filter(({ tab }) => matchesTab(tab, query)) + .map(({ tab, key }) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/TabText.tsx b/src/renderer/src/features/browser/open-in-browser/ui/TabText.tsx new file mode 100644 index 0000000..95960fa --- /dev/null +++ b/src/renderer/src/features/browser/open-in-browser/ui/TabText.tsx @@ -0,0 +1,11 @@ +import type { DrivenTab } from '@common/types'; + +/** A tab's title (its address when it has none) over its address. */ +export function TabText({ tab }: { tab: Pick }) { + return ( + <> + {tab.title || tab.url} + {tab.url} + + ); +} diff --git a/src/shared/ipcChannels.ts b/src/shared/ipcChannels.ts index 71de86d..3d87700 100644 --- a/src/shared/ipcChannels.ts +++ b/src/shared/ipcChannels.ts @@ -96,6 +96,7 @@ export const IPC_CHANNEL = { listDriven: 'browsers:driven', activateTab: 'browsers:activate-tab', stopDriving: 'browsers:stop-driving', + listEverydayTabs: 'browsers:everyday-tabs', listShots: 'shots:list', captureShot: 'shots:capture', diff --git a/src/shared/types/browsers.ts b/src/shared/types/browsers.ts index e18f3c8..b1d90f9 100644 --- a/src/shared/types/browsers.ts +++ b/src/shared/types/browsers.ts @@ -24,6 +24,21 @@ export interface BrowserInfo { hidden: boolean; } +/** A tab of your everyday browser (one the app didn't launch), as its session has it. */ +export interface EverydayTab { + title: string; + url: string; +} + +/** Your everyday browser's tabs, for one of its profiles. */ +export interface EverydayBrowser { + /** The profile's folder. */ + id: string; + name: string; + profile: string; + tabs: EverydayTab[]; +} + /** A tab of a browser the app drives. */ export interface DrivenTab { /** Its target id. */ diff --git a/src/shared/types/browsersApi.ts b/src/shared/types/browsersApi.ts index f60ea00..3ef13d4 100644 --- a/src/shared/types/browsersApi.ts +++ b/src/shared/types/browsersApi.ts @@ -1,4 +1,4 @@ -import type { BrowserInfo, DrivenBrowser } from './browsers'; +import type { BrowserInfo, DrivenBrowser, EverydayBrowser } from './browsers'; /** The other browsers' part of the API exposed to the renderer (`ConsoleEditorApi`). */ export interface BrowsersApi { @@ -21,6 +21,8 @@ export interface BrowsersApi { listDriven(): Promise; /** Brings a tab of a driven browser to the front. */ activateTab(browserId: string, tabId: string): Promise; + /** The tabs open in your everyday Firefox, from its session file (read when asked, and nowhere else). */ + listEverydayTabs(): Promise; /** Stops serving the workspace's changes in a driven browser (it stays open, as it is). */ stopDriving(browserId: string): Promise; } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 0d8290b..84e9314 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -8,7 +8,7 @@ export type { ActionInput, ActionPatch, ActionsWindowState, ConsoleAction } from export type { Breakpoint, BreakpointStage, FailReason, HeldAction, HeldActionType, HeldRequest, HeldResponse } from './breakpoints'; export { BREAKPOINT_STAGES, FAIL_REASONS } from './breakpoints'; export type { ConsoleEditorApi } from './api'; -export type { BrowserEngine, BrowserInfo, DrivenBrowser, DrivenEngine, DrivenTab } from './browsers'; +export type { BrowserEngine, BrowserInfo, DrivenBrowser, DrivenEngine, DrivenTab, EverydayBrowser, EverydayTab } from './browsers'; export { BROWSER_ENGINES, DRIVEN_ENGINES } from './browsers'; export type { BrowsersApi } from './browsersApi'; export type { ConsoleEntry, ConsoleFrame, ConsoleLevel, ConsoleLocation, ConsoleProperty, ConsoleSource, ConsoleValue, ConsoleValueKind } from './console'; diff --git a/test/e2e/everydayTabs.e2e.test.ts b/test/e2e/everydayTabs.e2e.test.ts new file mode 100644 index 0000000..18222a5 --- /dev/null +++ b/test/e2e/everydayTabs.e2e.test.ts @@ -0,0 +1,86 @@ +/** + * Your everyday Firefox's tabs, in the built app on Linux: with Firefox installed, the browser menu offers its tabs; + * asked, it lists them from the session file of your Firefox profile (read only then), the search narrows them, and + * choosing one loads its address in the app. + */ +import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { mozLz4 } from '../helpers/mozLz4'; + +const root = resolve(__dirname, '../..'); +const built = existsSync(join(root, 'out/main/index.js')); +// Chromium's sandbox can't start as root (containers). +const sandboxArgs = process.getuid?.() === 0 ? ['--no-sandbox'] : []; +const EDITOR_URL = /\/renderer\/index\.html$/; + +/** Polls until `fn` returns a truthy value (usable outside tests, unlike expect.poll). */ +async function waitFor(fn: () => T | undefined | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!built || process.platform !== 'linux')('Your Firefox tabs', () => { + let server: Server; + let origin: string; + let dir: string; + let app: ElectronApplication; + let win: Page; + const menu = () => win.getByTestId('browser-menu'); + + beforeAll(async () => { + server = createServer((req, res) => void res.writeHead(200, { 'content-type': 'text/html' }).end(`${new URL(req.url ?? '/', 'http://x').pathname}`)); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + dir = await mkdtemp(join(tmpdir(), 'console-editor-e2e-everyday-')); + // A Firefox launcher (never started here), and a Firefox profile with a session in the home folder. + mkdirSync(join(dir, 'data/applications'), { recursive: true }); + mkdirSync(join(dir, 'system'), { recursive: true }); + const program = join(dir, 'firefox'); + writeFileSync(program, '#!/bin/sh\n'); + chmodSync(program, 0o755); + writeFileSync(join(dir, 'data/applications/firefox.desktop'), `[Desktop Entry]\nType=Application\nName=Firefox\nExec=${program} %u\nCategories=Network;WebBrowser;\n`); + const firefox = join(dir, 'home/.mozilla/firefox'); + mkdirSync(join(firefox, 'Profiles/a.default-release/sessionstore-backups'), { recursive: true }); + writeFileSync(join(firefox, 'profiles.ini'), '[Profile0]\nName=default-release\nIsRelative=1\nPath=Profiles/a.default-release\n'); + const tab = (path: string, title: string) => ({ index: 1, entries: [{ url: `${origin}${path}`, title }] }); + writeFileSync(join(firefox, 'Profiles/a.default-release/sessionstore-backups/recovery.jsonlz4'), mozLz4({ windows: [{ tabs: [tab('/cart', 'Your cart'), tab('/account', 'Your account'), { index: 1, entries: [{ url: 'about:newtab' }] }] }] })); + + app = await electron.launch({ + args: [...sandboxArgs, root], + cwd: root, + env: { ...process.env, HOME: join(dir, 'home'), CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, + }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + }); + + afterAll(async () => { + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(dir, { recursive: true, force: true }); + }); + + it('lists them once asked, narrowed by the search, and loads one here', async () => { + await win.getByTestId('browser-menu-button').click(); + // Nothing is read before it is asked for. + expect(await menu().getByTestId('everyday-tabs').count()).toBe(0); + await menu().getByTestId('everyday-show').click(); + await expect.poll(() => menu().getByTestId('everyday-tab').allInnerTexts()).toEqual([`Your cart\n${origin}/cart`, `Your account\n${origin}/account`]); + expect(await menu().getByTestId('everyday-tabs').innerText()).toContain('Firefox · default-release'); + await menu().getByRole('textbox', { name: 'Search browsers' }).fill('account'); + await expect.poll(() => menu().getByTestId('everyday-tab').count()).toBe(1); + await menu().getByTestId('everyday-tab').click(); + await expect.poll(() => win.getByTestId('address-bar').inputValue(), { timeout: 15_000 }).toBe(`${origin}/account`); + }); +}); diff --git a/test/fixtures/firefox/recovery.jsonlz4 b/test/fixtures/firefox/recovery.jsonlz4 new file mode 100644 index 0000000000000000000000000000000000000000..7c9731dd610bc905aee4024fae5133078bb3d1a4 GIT binary patch literal 1297 zcmWkuOOF&)82zfcdxjZC2HF`hV+?R(jK;1^y&oMgj00mj1A)jqd<+@xZ-OL zFf*NwMk6d(NO0kD6iLX~3tbs^5?#1a|A7SmfN2oI60fUD-TU3|o^!r)Za3XIvt#S9 zwhv&W^vM;>@+eJ6^8=D&z8@YvmtkI{8Ip!^u|cjziAb;Ja=T9o$|iteQe+WM!J`+; zEGA7NXhE9=ZWZ&Lq8m4k7f})8n@dra7f0oZ+skmeeOHVH-)8np6h6KU~rA;p`)1$J(F>2S*9_NO(oPaF3x9B z!lNFISKn!oW>Us+gY;%3yOp$+bTEsGO-6Hc?M>htMBgCIak#WWy6KMQLd}NJOU=F| zsP1z|*9@B*n(f(4W6TzsX9ZRe1T18sGuR-5&*33-Xug_b)=CP@C@(%%o~R*>b8HZ) zOuH>FOxkx~;yN6(u7h$KMyll35>mJ*j4+dw%@a`4B#-3MIk=S2D^Z&=P!^P%q>rwF zo=tDVXwpK>b{s?VE$(S3Bo2$Rz;vB&VJv`SE#bqH6T+jW2zFdq`8~FR1X<@%EV9|&3edOA6clOVN2*9vo+=#rslgo6<%PXuH$FQ^iW-THsf)h zxmL(DhlaA&2rbPQ(jcbcaVzw6j`mPpK4J~k`e@eBL*3MMUo*JNH9Pb}E%2zPxt33L zq{7l2_cdi|s6Lw&21UbUa?sqhG@D8XP()kPg%nN#@OWSh)yZ>~8+b$Yef6ojyRzLw zu}0(aNNs$&R8_Dl%5gZk7$cpm!nMlzc9xdC+qJt=a~`Ks?YHY7{TCxIzOG2EH;+Pn z<$mp?+~54Qa%j;3V}&vwZY`!|Qjn%*Jg7{*46s*$12yR?R_5D(RUX!PCri7y6w|iM zqiMLFWBH!xH%Q)#qhc+>^)W?^>7wZTQhhvS26CzxwL7w-8yK>RXNbwWq-jq<{kb z|J}#Z^m3PGm-EwGa#f&MN@Ic9x^gR~Wg?TFt^W)!=yj;hLv6oOeFM&~MR~-YjVb@s z;E(D;zR+6d(a07IAt{cs&= 15) { + let rest = data.length - 15; + for (; rest >= 255; rest -= 255) head.push(255); + head.push(rest); + } + return Buffer.concat([Buffer.from(head), data]); +} + +/** A Firefox `.jsonlz4` file of `value` (its session file's format). */ +export function mozLz4(value: unknown): Buffer { + const json = Buffer.from(JSON.stringify(value)); + const size = Buffer.alloc(4); + size.writeUInt32LE(json.length); + return Buffer.concat([Buffer.from('mozLz40\0', 'latin1'), size, lz4Literals(json)]); +} diff --git a/test/unit/everydayTabs.test.ts b/test/unit/everydayTabs.test.ts new file mode 100644 index 0000000..862f87c --- /dev/null +++ b/test/unit/everydayTabs.test.ts @@ -0,0 +1,75 @@ +/** + * Your everyday Firefox's tabs: its compressed session file (`mozLz40`, an LZ4 block) read, including one Firefox + * wrote, its profiles found from profiles.ini (the install's default first), and the page each tab shows, on the web. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { listEverydayTabs } from '../../src/main/browsers/everyday'; +import { lz4Block } from '../../src/main/browsers/everyday/lz4Block'; +import { profilesOf } from '../../src/main/browsers/everyday/profilesOf'; +import { readMozLz4 } from '../../src/main/browsers/everyday/readMozLz4'; +import { sessionTabsOf } from '../../src/main/browsers/everyday/sessionTabsOf'; +import { mozLz4 } from '../helpers/mozLz4'; + +const tmp = mkdtempSync(join(tmpdir(), 'console-editor-everyday-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const session = { + windows: [ + { tabs: [{ index: 2, entries: [{ url: 'https://old.test/' }, { url: 'https://shop.test/cart', title: 'Cart' }] }, { index: 1, entries: [{ url: 'about:preferences' }] }] }, + { tabs: [{ entries: [{ url: 'http://localhost:3000/', title: '' }] }] }, + ], +}; + +describe('A Firefox session file', () => { + it('is decompressed: one Firefox wrote (matches and all), and one of literals only', async () => { + const real = JSON.parse((await readMozLz4(join(__dirname, '../fixtures/firefox/recovery.jsonlz4')))!) as { windows: unknown[] }; + expect(real.windows).toHaveLength(1); + const path = join(tmp, 'plain.jsonlz4'); + writeFileSync(path, mozLz4(session)); + expect(JSON.parse((await readMozLz4(path))!)).toEqual(session); + }); + + it("is refused when it isn't one: no header, or a block pointing before its start", async () => { + const path = join(tmp, 'bad.jsonlz4'); + writeFileSync(path, 'not a session'); + expect(await readMozLz4(path)).toBeNull(); + expect(await readMozLz4(join(tmp, 'missing.jsonlz4'))).toBeNull(); + // A literal, then a match 9 bytes back where only 1 was written. + expect(() => lz4Block(Uint8Array.from([0x10, 0x41, 0x09, 0x00]), 10)).toThrow('Not an LZ4 block'); + // An overlapping match repeats the byte before it. + expect(lz4Block(Uint8Array.from([0x10, 0x41, 0x01, 0x00, 0x00]), 10).toString()).toBe('AAAAA'); + }); + + it('lists the page each tab shows, on the web only, titled by its address when it has no title', () => { + expect(sessionTabsOf(JSON.stringify(session))).toEqual([ + { url: 'https://shop.test/cart', title: 'Cart' }, + { url: 'http://localhost:3000/', title: 'http://localhost:3000/' }, + ]); + expect(sessionTabsOf('{')).toEqual([]); + }); +}); + +describe("Firefox's profiles", () => { + it("are listed from profiles.ini, the install's default first, relative ones in the data folder", () => { + const ini = ['[General]', 'Version=2', '', '[Profile1]', 'Name=work', 'IsRelative=1', 'Path=Profiles/w.work', '', '[Profile0]', 'Name=default-release', 'IsRelative=1', 'Path=Profiles/d.default-release', '', '[Profile2]', 'Name=elsewhere', 'IsRelative=0', 'Path=/data/ff', '', '[Install4F96D1932A9F858E]', 'Default=Profiles/d.default-release'].join('\n'); + expect(profilesOf('/home/me/.mozilla/firefox', ini)).toEqual([ + { name: 'default-release', dir: '/home/me/.mozilla/firefox/Profiles/d.default-release' }, + { name: 'work', dir: '/home/me/.mozilla/firefox/Profiles/w.work' }, + { name: 'elsewhere', dir: '/data/ff' }, + ]); + }); + + it("give the tabs of each profile with a session, from the system's own Firefox folder", async () => { + const home = join(tmp, 'home'); + const data = join(home, '.mozilla/firefox'); + mkdirSync(join(data, 'Profiles/d.default/sessionstore-backups'), { recursive: true }); + mkdirSync(join(data, 'Profiles/e.empty'), { recursive: true }); + writeFileSync(join(data, 'profiles.ini'), '[Profile0]\nName=default\nIsRelative=1\nPath=Profiles/d.default\n[Profile1]\nName=empty\nIsRelative=1\nPath=Profiles/e.empty\n'); + writeFileSync(join(data, 'Profiles/d.default/sessionstore-backups/recovery.jsonlz4'), mozLz4(session)); + expect(await listEverydayTabs(home, 'linux')).toEqual([{ id: join(data, 'Profiles/d.default'), name: 'Firefox', profile: 'default', tabs: sessionTabsOf(JSON.stringify(session)) }]); + expect(await listEverydayTabs(home, 'aix')).toEqual([]); + }); +}); From 279e518a96d0fab63f6ed4f289243f1791437627 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Mon, 28 Sep 2026 22:43:55 +0000 Subject: [PATCH 10/19] Document other browsers, captures and designs SPEC gains the sections on other browsers (finding and opening them, driving Chromium over CDP and Firefox over WebDriver BiDi, your everyday Firefox's tabs), captures and designs (capturing, the group capture, the compare page) and the design over the page. It also covers their data and storage, source layout, security, tests, stories and milestones. The CHANGELOG's Unreleased section and the README describe what users get, what is kept where, and Firefox's limits. The research doc now says what was built, item by item. It records why WebKit through Playwright (phase 8) isn't built yet, and how to do it. --- CHANGELOG.md | 8 ++ README.md | 17 +++- docs/BROWSERS_RESEARCH.md | 102 +++++++++++++----------- docs/SPEC.md | 161 ++++++++++++++++++++++++++++++++------ 4 files changed, 218 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2bb40..e5c6042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - **Edit overrides in VS Code.** **Open in VS Code**, on an override's tab, its right-click menu in the Explorer or the command palette, opens its file there. Save it in VS Code, or any other editor, and the page reloads with your version, just as if you had saved in the app. A tab left open shows the new text; if it has edits you haven't saved, it keeps them and offers the saved file's version instead. **Show in folder** finds the file for any other editor. - **Share your overrides.** The new menu on the Explorer's **Overrides** section (or the command palette) exports a workspace's overrides and rules as one file, and imports one: send it to a teammate and they see the page with your fix, or copy your changes to another workspace. An import adds only what the workspace doesn't have yet, so your own versions are kept, and tells you what it left out. +- **Open the page in your other browsers.** The icons beside the address bar open a menu of the browsers on your computer (Chrome, Firefox, Safari, Edge, Brave…), each with its version: pick one and the page opens there, to see whether something looks broken in it. The command palette has them too (**Open in Firefox**), and **Settings › Browsers** hides the ones you don't want offered, or adds one the app didn't find. +- **Open it with your changes.** Chromium browsers and Firefox also offer **Open in … with your changes**: the app starts the browser with a profile of its own and serves your overrides and rules in every tab you open there, reloading them after a change as it does its own page. The browser menu lists those tabs: bring one to the front, open it in the app or capture it. The browser stays open when you let go of it, and the app finds it again next time. In Firefox, an override replaces the whole response (its headers are its file type's) and patch mode serves your saved text. +- **Captures.** The new button at the end of the preview's toolbar captures what the page shows, the whole page or one element you pick (even in a cross-site frame), and lists your captures with their thumbnails, newest first, with a search. A capture's page shows it at any zoom, down to its pixels and the colour under the pointer; copy it, save a copy or rename it. Each workspace keeps its own. +- **Check the page against a design.** Import a design (PNG, JPEG or WebP; drop or paste it on the menu too; a name with `@2x` sets its scale) and compare it with the page captured at the design's width: side by side, swiped, faded over it, or as their difference, with how much of the page differs and where. +- **A design over the live page.** **Put over the page** lays a design over the page itself, at its width, with its opacity, a difference blend and a nudge to line it up, even on a site whose Content-Security-Policy is strict. It stays through reloads and out of your captures. +- **Capture in every browser.** **Capture › In every browser** captures the whole page in the app and in every browser you opened with your changes, at the same size, and shows them side by side against the app's capture or a design, each with how much of it differs. +- **Your Firefox tabs.** The browser menu can list the tabs open in your everyday Firefox: pick one to open its page in the app. + ### Changed - **No more white title bar on Linux.** The editor's window no longer has the system's light title bar above its own. The app's dark title bar is now the top of the window: drag it to move the window, and the minimize, maximize and close buttons sit at its right end, in its colours. diff --git a/README.md b/README.md index 07b0d38..7486542 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ Each workspace keeps its own page, tabs, overrides, rules and actions, so a fix Right-click a file to block it (an analytics script, a slow third-party iframe) before it reaches the server, or to remove a page's Content-Security-Policy, with an Undo. Rules can also set or remove any response header, or let the page call an API on another origin, preflights and cookies included. Each rule shows how often it applied and to which URLs. +### Check it in other browsers and against designs + +- **Your other browsers** (Chrome, Firefox, Safari, Edge…) are a click away beside the address bar. Chromium browsers and Firefox can also open the page **with your changes**: the app serves your overrides and rules in their tabs too, and lists those tabs. +- **Capture** what the page shows, the whole page or one element, here or in those browsers; **Capture in every browser** takes them all at once, at the same size, and shows where they differ. +- **Compare with a design** pixel by pixel: side by side, swiped, faded, or as their difference with how much differs and where, or lay the design over the live page at its width. + ### And - **Never lose work.** Closing the app keeps unsaved edits as drafts and reopens your tabs and the last page; overrides switch on and off one by one. @@ -205,8 +211,13 @@ Everything stays on your machine: no telemetry, no uploads. Besides the sites yo | Settings | `settings.json` | | The last version run, to know when to show What's New | `update.json` | | Workspaces: each one's name and tile, last page, open tabs, unsaved drafts and site icon | `session/` | +| Captures and designs, with their thumbnails | `workspace/shots.json`, `workspace/shots/` | +| Browsers you added or hid | `browsers.json` | +| The profiles of browsers opened with your changes (their logins stay there) | `browsers/` | | The site's cookies, logins, storage | A persistent browser profile used only by the site view | +Your everyday Firefox's session file is read only when you ask the browser menu for its tabs, and only their addresses and titles are shown. + The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application Support/Console Editor` on macOS and `%APPDATA%\Console Editor` on Windows. Uninstalling the app keeps it. Running from source uses a separate `Console Editor (dev)` folder next to it, so a dev build never touches your real data. Set `CONSOLE_EDITOR_USER_DATA` to use another folder. ## Limitations @@ -221,6 +232,8 @@ The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application S - A response override with **Send request** on (the default for a GET) answers after the server has: the request is still sent, so a POST would still create what it creates, which is why overrides made from anything but a GET start with it off. An event stream an override matches is replaced as a whole, which ends it. - A paused request waits only as long as the page does: if the page gives up on it (a timeout, leaving the page), its tab closes with a note. - WebSocket messages are shown, not changed: Chromium reports them but can't hold or edit them. A HAR import makes overrides for fetch and XHR responses only, not for documents or scripts. +- In Firefox opened with your changes, an override answers before the request is sent (Firefox can only replace a response then): its headers are its file type's rather than the server's, patch mode serves your saved text, an override for one GraphQL operation doesn't apply, and a script behind an `integrity` attribute needs the page's HTML overridden too. Breakpoints, the console and the Network panel stay with the app's own page. Safari is opened as it is. +- A full-page capture stops at 16 384 device pixels tall (Chromium's limit). - Chromium's local-network checks are off in the app's browser, so a patched localhost or intranet page can still reach its own servers. Browse only sites you're working on (see [SPEC §8](docs/SPEC.md#8-security)). ## Roadmap @@ -229,8 +242,8 @@ What shipped, release by release, is in the **[CHANGELOG](CHANGELOG.md)**. Next: - [ ] Parameters and scenarios for actions - [ ] Search across every file the page loaded -- [ ] Drive your own Chrome over CDP -- [ ] Open the page in your other browsers (with your changes in Chromium browsers and Firefox), capture it, and check it against designs pixel by pixel ([research](docs/BROWSERS_RESEARCH.md)) +- [ ] Drive your everyday Chrome over CDP (a Chromium browser with a profile of the app's own already works) +- [ ] WebKit with your changes through Playwright's build, the design over another browser's tab, and tabs of your other everyday browsers ([research](docs/BROWSERS_RESEARCH.md)) - [ ] Vue's renders and a data-flow view in the component inspector ([research](docs/INSPECTOR_RESEARCH.md)) - [ ] Signed and notarized builds (and with them, installing updates in place on macOS) diff --git a/docs/BROWSERS_RESEARCH.md b/docs/BROWSERS_RESEARCH.md index 3561237..dd87f52 100644 --- a/docs/BROWSERS_RESEARCH.md +++ b/docs/BROWSERS_RESEARCH.md @@ -1,5 +1,7 @@ # Research: other browsers, captures and designs +**Status.** Phases 1 to 7 are built (SPEC §6.16–§6.18), and from phase 9 your everyday Firefox's tabs. Phase 8 (WebKit) is not: what stands in its way, and how to do it, is under [Phase 8](#phase-8-webkit). The to-do list below says, item by item, what was built and where it went another way. + **Question.** Two things in one request: 1. **Other browsers.** Show the browsers installed on this computer next to the address bar. Open the page in any of them to see whether something is broken there, and search the tabs they have open. @@ -223,75 +225,85 @@ renderer widgets/ browser-menu, shots-menu, shot-page (viewer and co Each phase ends in a pull request of its own. Each updates SPEC, the CHANGELOG and, where it shows, the README. ### Phase 1: Open in another browser -- [ ] `findBrowsers` for Linux, macOS and Windows, each tested on fixtures (`.desktop` files, `reg query` output, app folders). -- [ ] Icons: the Linux icon theme lookup, and `app.getFileIcon` on macOS and Windows. Each is cached as a data URL. -- [ ] `openInBrowser(id, url)`: a detached spawn, `open -b` on macOS, `gtk-launch`, or the `Exec=` line on Linux. -- [ ] IPC (`listBrowsers`, `openInBrowser`), zod-checked, and the `browsers-changed` event. -- [ ] `entities/browser`, and the browser button with its popover in `PreviewToolbar`, in both placements (search, one row per browser, **Open**). -- [ ] Palette items, and Settings › Browsers: which ones to show, and Add a browser…. -- [ ] Tests: unit tests for the parsers; e2e with a fake browser (a script that writes the address it got to a file). +- [x] `findBrowsers` for Linux, macOS and Windows, each tested on fixtures (`.desktop` files, `reg query` output, app folders). +- [x] Icons: the Linux icon theme lookup, and `app.getFileIcon` on macOS and Windows. Each is cached as a data URL. +- [x] `openInBrowser(id, url)`: a detached spawn of the browser's own command: `open -a` on macOS, the `Exec=` line on Linux (Flatpak's through `flatpak run`), the registry's command on Windows. +- [x] IPC (`listBrowsers`, `openInBrowser`), zod-checked, and the `browsers-changed` event. +- [x] `entities/browser`, and the browser button with its popover in `PreviewToolbar`, in both placements (search, one row per browser, **Open**). +- [x] Palette items, and Settings › Browsers: which ones to show, and Add a browser…. +- [x] Tests: unit tests for the parsers; e2e with a fake browser (a script that writes the address it got to a file). ### Phase 2: Captures -- [ ] `captureOverCdp`: the viewport, the full page (with the height cap) and an element, on a `CdpTransport`. Probe it on the app's `WebContentsView` first. -- [ ] `ShotStore`: files, thumbnails, `shots.json`, limits, removal with the workspace (in `WorkspaceController`'s delete order). -- [ ] IPC: `capture`, `listShots`, `renameShot`, `deleteShot`, `showShotInFolder`, `saveShotAs`, `copyShot`. -- [ ] `entities/shot`, and the shots button (stacked thumbnails) and its popover: search, segments, rows and row menu. -- [ ] The shot page: an editor tab kind with the viewer (zoom, pixel grid, colour under the pointer, details). -- [ ] An element capture from the inspector's picker, and palette items. -- [ ] Tests: unit tests for the store; integration tests for full-page and element clips in Chromium; e2e: capture, list, rename, delete, restart. +- [x] `captureOverCdp`: the viewport, the full page (with the height cap) and an element, on a `CdpTransport`. Probe it on the app's `WebContentsView` first. +- [x] `ShotStore`: files, thumbnails, `shots.json`, limits, removal with the workspace (in `WorkspaceController`'s delete order). +- [x] IPC: `capture`, `listShots`, `renameShot`, `deleteShot`, `showShotInFolder`, `saveShotAs`, `copyShot`. +- [x] `entities/shot`, and the shots button (stacked thumbnails) and its popover: search, segments, rows and row menu. +- [x] The shot page: an editor tab kind with the viewer (zoom, pixel grid, colour under the pointer, details). +- [x] An element capture from the inspector's picker, and palette items. +- [x] Tests: unit tests for the store; integration tests for full-page and element clips in Chromium; e2e: capture, list, rename, delete, restart. ### Phase 3: Designs and the compare page -- [ ] **Import design…**, dropping files, pasting: decode and measure in the renderer, guess the scale, keep the file. -- [ ] The compare page: side by side, swipe, onion skin, difference, and offsets. -- [ ] The diff worker: YIQ distance, anti-aliasing, the share of pixels that differ, differing areas. Measure it on a 1440×10 000 page. -- [ ] "Compare with the page": capture at the design's width (`Emulation.setDeviceMetricsOverride`), then compare, then clear the emulation. -- [ ] Tests: unit tests for the diff (identical, shifted and anti-aliased images); e2e: import a PNG, compare it with a capture of a fixture page, check the share of differing pixels. +- [x] **Import design…**, dropping files, pasting: decode and measure in the renderer, guess the scale, keep the file. +- [x] The compare page: side by side, swipe, onion skin, difference, and offsets. +- [x] The diff worker: YIQ distance over a threshold, the share of pixels that differ, differing areas (16 px cells, at most 200). Anti-aliased pixels aren't told apart (the threshold stands in), and it wasn't measured on a 1440×10 000 page. +- [x] "Compare with the page": capture at the design's width (`Emulation.setDeviceMetricsOverride`), then compare, then clear the emulation. +- [x] Tests: unit tests for the diff (identical, shifted and anti-aliased images); e2e: import a PNG, compare it with a capture of a fixture page, check the share of differing pixels. ### Phase 4: The overlay on the live page -- [ ] The injected overlay (canvas, `pointer-events: none`, scroll or fixed, put back if removed), installed in an isolated world on every load. -- [ ] `DesignOverlay` on CDP: install, update (opacity, blend, offset, invert, hidden) and remove it. Updates are small messages, and the image is sent once. -- [ ] Device emulation for the design's width in the docked view and in the website's own window (probe the scaling first). This also delivers M2's device presets, if wanted. -- [ ] The overlay bar and its keys, and hiding the overlay while capturing. -- [ ] Tests: e2e on a fixture page with a strict CSP: the canvas is there, the page still gets clicks, it survives a reload, and it's gone after Remove. +- [x] The injected overlay (canvas, `pointer-events: none`, scroll or fixed, put back if removed), installed in an isolated world on every load. +- [x] `DesignOverlay` on CDP: install, update (opacity, blend, offset, invert, hidden) and remove it. Updates are small messages, and the image is sent once. +- [x] Device emulation for the design's width in the docked view and in the website's own window: Electron's `enableDeviceEmulation` (CDP's scale draws wrong in a `WebContentsView`). M2's device presets aren't built on it yet. +- [x] The overlay bar and its keys, and hiding the overlay while capturing. +- [x] Tests: e2e on a fixture page with a strict CSP: the canvas is there, the page still gets clicks, it survives a reload, and it's gone after Remove. ### Phase 5: Chromium browsers with your changes (M3) -- [ ] `launchChromium` (the flags above, a profile under `userData`, the Snap and Flatpak paths) and `readDevToolsActivePort`; reuse a browser that's still running. -- [ ] `ChromiumDriver`: target discovery, `attachToPage` and a `PageInterception` per tab, following workspace switches and override and rule changes. It reloads after saves, cleans up when the browser closes, and survives a lost connection. -- [ ] Tabs in the browser menu (search, bring forward, Open here), the dot, the status bar count, and **Open with my changes**. -- [ ] Captures and the overlay in those tabs, through the same CDP code. -- [ ] Probe Chrome 144's `chrome://inspect` permission for the everyday profile (U14). -- [ ] Tests: integration with the Chromium harness (an override served in a launched browser's tab, a rule applied, a reload after a save); e2e with Playwright's Chromium as the outside browser. +- [x] `launchChromium` (the flags above, a profile under `userData`, the Snap and Flatpak paths) and `readDevToolsActivePort`; reuse a browser that's still running. +- [x] `DrivenChromium`: browser-level auto-attach (a new tab waits until it is set up), a `PageInterception` per tab, following workspace switches and override, rule and settings changes. It reloads after saves, forgets a browser that closes, and reaches it again through its profile. +- [x] Tabs in the browser menu (search, bring forward, Open here), the dot, the status bar count, and **Open with my changes**. +- [x] Captures in those tabs, through the same CDP code. Not yet: the overlay in them. +- [ ] Probe Chrome 144's `chrome://inspect` permission for the everyday profile (U14): no Chrome to probe it with in the development container. +- [x] Tests: integration with Playwright's Chromium launched as the installed browser (an override served, a reload after a change, captures, reached again, forgotten once quit); e2e with a launcher running it. ### Phase 6: Capture in every browser -- [ ] One capture in each driven browser at the same address, viewport and density, with settle rules; saved as a group. -- [ ] The group's compare grid against a baseline, with the share of differing pixels per browser. -- [ ] Tests: e2e with the app's page and one launched Chromium. +- [x] One capture in each driven browser at the same address, viewport and density, with settle rules; saved as a group. +- [x] The group's compare grid against a baseline, with the share of differing pixels per browser. +- [x] Tests: e2e with the app's page and one launched Chromium. ### Phase 7: Firefox with your changes -- [ ] Probe BiDi in the current Firefox: `addIntercept`, `provideResponse` with a body, headers at `responseStarted`, `addDataCollector` and `getData`, and preload scripts in cross-site iframes. Write down what holds. -- [ ] `launchFirefox` (a profile with `user.js` prefs for no first run and no updates), and `BidiConnection`. -- [ ] `BidiServer`: patterns from the overrides and rules, the request and response stages, and the plain engine functions. Redeploy detection and patch mode only where bodies can be read. -- [ ] Tabs (`browsingContext.getTree`), captures (`captureScreenshot`), the overlay (preload script), the viewport (`setViewport`). -- [ ] Tests: integration tests against Firefox, skipped where it isn't installed; add Firefox to one CI job. +- [x] Probe BiDi in the current Firefox (157): `addIntercept` and headers at `responseStarted` hold; `provideResponse` takes a body only before the request is sent; `getData` reads a body only once the response has completed (so no patching in flight); `setCacheBehavior` stands in for reload's `ignoreCache`; the address is in the profile's `WebDriverBiDiServer.json`. Preload scripts weren't tried in cross-site iframes. +- [x] `launchFirefox` (a profile with `user.js` prefs for no first run and no updates), and `BidiConnection`. +- [x] `BidiInterception`: every request paused while an override or a rule is on (BiDi's URL patterns can't express globs and regexes), overrides answered before sending (`engine/answering`, shared), rules at both stages, the plain engine functions. Redeploy detection and patch mode are off: no body can be replaced once upstream answered. +- [x] Tabs (`browsingContext.getTree`, titles read in each page), captures (`captureScreenshot`), the viewport (`setViewport`). Not yet: the overlay. +- [x] Tests: integration and e2e tests against Firefox, skipped where it isn't found (`FIREFOX_PATH`, or `firefox` on the PATH). GitHub's Ubuntu runners ship Firefox, so CI runs them without a change. ### Phase 8: WebKit -- [ ] Decide between Playwright's WebKit (downloaded on use) and nothing, after measuring the download and checking the licence text to ship. + +Not built. What stands in the way, found while building phases 5 to 7: + +- **Nothing to check it with.** Playwright's CDN (and Mozilla's download hosts) are refused by the development container's network, so no WebKit build could be downloaded or run. Firefox came from conda-forge's package of Mozilla's build; there is none of Playwright's WebKit. +- **Packaging.** The build bundles the main process (`externalizeDeps: false`) and ships no `node_modules`. `playwright-core` can't be bundled: when it loads it `require`s its own `package.json` and `browsers.json` by paths it computes, and its installer forks `lib/entry/oopBrowserDownload.js` by path. Shipping it means `playwright-core` in the dependencies (pinned), left out of the bundle, and its folder in the packaged app (unpacked from the asar for the fork), about 14 MB. Only the release workflow's packaged tests can show that works. +- **The download** would be `cli.js install webkit` with `PLAYWRIGHT_BROWSERS_PATH=/browsers/playwright`, run by Electron as Node, about 90 MB, and on Linux system libraries only an administrator can install (`install-deps`). + +How it would go, on a machine that can reach Playwright's CDN, with CI installing WebKit for its tests (`npx playwright install --with-deps webkit`): +- [ ] The packaging change above, checked by the release workflow's packaged tests. - [ ] The download with progress, and removing it from Settings. -- [ ] A driver on `playwright-core`: `route` for overrides and rules, `screenshot`, viewport, and `addInitScript` for the overlay. +- [ ] A driver on `playwright-core` behind the `Driver` interface (`browsers/driven/types.ts`): `route` carries out `decideRequest` (fulfil, abort, continue) and, through `route.fetch`, `ruledHead` for header rules; `page.screenshot` for captures; a context per capture viewport (`deviceScaleFactor` is a context's). - [ ] Tests: skipped where the build isn't there. ### Phase 9: Later -- [ ] Tabs of everyday browsers: AppleScript on macOS, Firefox's session file everywhere. +- [ ] Tabs of everyday browsers: AppleScript on macOS (can't be checked on Linux). +- [x] Firefox's session file everywhere: `recovery.jsonlz4` of each profile in `profiles.ini`, read only when asked (SPEC §6.16). - [ ] Figma frames by link (a personal token, kept with `safeStorage`). -- [ ] Stitching full pages taller than the texture limit. +- [ ] Stitching full pages taller than the texture limit (a PNG codec in the main process, and hundreds of MB per capture). - [ ] The console, Network and inspector for an outside tab. +- [ ] The design over a driven browser's tab (Chromium: the same isolated-world script; Firefox: a preload script in a sandbox, which the probe showed works). ## 7. Limits and risks | Risk | Mitigation | |---|---| -| Safari can't be served changes | Say so in the menu, and offer WebKit (phase 8) | -| Firefox can't read response bodies before some version | Redeploy detection and patch mode stay off there, and the menu says so | +| Safari can't be served changes | It is only opened as it is; WebKit through Playwright (phase 8) is still to come | +| Firefox replaces a body only before the request is sent | Overrides answer then, with their file type's headers; redeploy detection and patch mode stay off there (README › Limitations) | | Snap and Flatpak sandboxes | Profile folders inside the sandbox. If a launch fails, show the browser's own output | | The page's scripts can see or remove the overlay | Add it to `` and put it back; documented | | Sticky headers and lazy images in full-page captures | A fixed element appears once, at the top. An option scrolls through the page first, so lazy images load | @@ -300,6 +312,8 @@ Each phase ends in a pull request of its own. Each updates SPEC, the CHANGELOG a ## 8. Decisions to make +Taken: the recommendations below, as built; the WebKit one waits on phase 8. + | Question | Recommendation | Alternative | |---|---|---| | Build order | Phases 1 → 2 → 3 → 4 first: useful on their own, and they need no outside browser. Then 5, 6, 7, 8 | Phase 5 first, since M3 and U14 are already on the roadmap | diff --git a/docs/SPEC.md b/docs/SPEC.md index 152d59e..d359624 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -51,11 +51,12 @@ A desktop app where you enter a website's URL, see every script, stylesheet and | U31 | I see which code sent a request: the stack of the script, traced to the originals; and on a component's page, the requests sent from its file | ✅ (§6.10, §6.13) | | U32 | I keep a state value I set on the Component page as an action, which sets it again whenever I run it (after a reload too) | ✅ (§6.13) | | U33 | I see the renders I recorded by component: how often each rendered, mounted or was skipped, how long its own renders took, and why | ✅ (§6.14) | -| U34 | I open the page in another browser installed on this computer (Chrome, Firefox, Safari, Edge…) from beside the address bar, to see whether something is broken there | 🔜 M5 ([research](BROWSERS_RESEARCH.md)) | -| U35 | I open it in a Chromium browser or Firefox with my overrides and rules applied, and search the tabs the app opened there | 🔜 M5 | -| U36 | I capture the page (the viewport, the whole page or one element), in the app or in another browser, and find my captures in a list at the end of the toolbar | 🔜 M5 | -| U37 | I import a design and check the page against it pixel by pixel: laid over the live page, or side by side, swiped, faded or as a difference | 🔜 M5 | -| U38 | I capture the page in every browser at once and see where they differ from each other or from the design | 🔜 M5 | +| U34 | I open the page in another browser installed on this computer (Chrome, Firefox, Safari, Edge…) from beside the address bar, to see whether something is broken there | ✅ (§6.16) | +| U35 | I open it in a Chromium browser or Firefox with my overrides and rules applied, and search the tabs the app opened there | ✅ (§6.16) | +| U36 | I capture the page (the viewport, the whole page or one element), in the app or in another browser, and find my captures in a list at the end of the toolbar | ✅ (§6.17) | +| U37 | I import a design and check the page against it pixel by pixel: laid over the live page, or side by side, swiped, faded or as a difference | ✅ (§6.17, §6.18) | +| U38 | I capture the page in every browser at once and see where they differ from each other or from the design | ✅ (§6.17) | +| U39 | I bring an address from my everyday Firefox into the app: its open tabs are listed in the browser menu | ✅ (§6.16) | ## 3. Architecture @@ -148,15 +149,28 @@ src/ data: handed over undecoded; 64 MB, 30 s), or picked from a file (§6.8) readCapped.ts capped streaming reads (favicons, source maps) desktopEntry/ Linux: the desktop entry and icons an AppImage or .tar.gz installs for itself (§10) + browsers/ the other browsers (§6.16): BrowserRegistry/ (found and added, their icons and versions), + findBrowsers/ (Linux desktop entries and icon themes, macOS apps, the Windows registry), + startBrowser.ts; driven/ (browsers driven with the workspace's changes: DrivenBrowsers, the + Driver interface and DrivenBase, DrivenTabs, launching with a profile and reaching one again; + chromium/ over CDP, firefox/ over WebDriver BiDi); everyday/ (your everyday Firefox's tabs, from + its session file) + shots/ PageShots/ (captures of the app's page, designs, groups), capture/ (over CDP: an area, at a + width or a viewport), captureInEveryBrowser.ts, image type and size, thumbnails, the + `console-editor-shot:` protocol (§6.17) + overlay/ DesignOverlay (the design over the app's page, fitting the page to its width) and PageOverlay + (its canvas in an isolated world) (§6.18) electronTransport.ts webContents.debugger → CdpTransport engine/ PageInterception/ (one engine per CDP session: page, iframes, workers; hands each frame session to the console too), InterceptionEngine/ (the coordinator, with frame, navigation, resource, paused-request, settings and worker-script collaborators), rules/ (block, header and CORS rules: matching and header edits), transform/ (SRI/source maps/headers, the SourceMap header a response names), - cdp/ (transport interface), websocketTransport/ (browser-level CDP, used by tests), + cdp/ (transport interface), websocketTransport/ (browser-level CDP: driven Chromium browsers, + and the tests), bidi/ (WebDriver BiDi: the connection, and Firefox's interception), answering/ + (what to do with a request where a browser can only answer before sending it), constants/ (CDP command and event names, HTTP status classes) - store/ OverrideStore/, SessionStore/, RuleStore/, ActionStore/, SourceMapFileStore/ (maps loaded from files) (each a store with its file and record helpers), SettingsStore.ts, + store/ OverrideStore/, SessionStore/, RuleStore/, ActionStore/, SourceMapFileStore/ (maps loaded from files), ShotStore/, BrowserStore/ (each a store with its file and record helpers), SettingsStore.ts, WindowStore/ (an own window's place: the website's, the Actions panel's), WriteQueue.ts, writeAtomic.ts, parseInput.ts (untrusted input read with a schema) and shared sanitizers update/ UpdateService/ (checks, downloads, installs: §10.1), electronInstaller/ (electron-updater), updateEndpoints.ts (GitHub, or a local update server in tests) @@ -171,7 +185,8 @@ src/ pages/editor/ the workspace layout and its persisted layout store; session sync and workspace switching pages/page-window/ the website's own window: the preview alone, toolbar and all (§7.1) pages/actions-window/ the Actions panel's own window (§6.9) - widgets/ title-bar, activity-bar, explorer, editor-panel, page-preview, status-bar, settings-panel, command-palette, + widgets/ title-bar, activity-bar, explorer, editor-panel (+ a shot's, compare and group pages), page-preview (+ the + shots menu and the overlay bar), status-bar, settings-panel, command-palette, console-panel, actions-panel, network-panel, inspect-panel (the Inspect view, with the Components tree), renders-panel (+ by component), stores-panel features/ open-resource (also original sources and the jumps between them and bundles), save-override, @@ -183,7 +198,9 @@ src/ toggle; delete), action/ (a group: run, edit, detach), edit-response-rule, network/ (a group: filter, clear, breakpoints, held, throttle, har, response-tree), inspect/ (a group: stack, the Page stack; pick, picking an element, the Component page and setting - state; tree, the Components tree; renders; stores) + state; tree, the Components tree; renders; stores), browser/ (a group: open-in-browser, the browser + menu with driven and everyday tabs; manage-browsers, Settings › Browsers), shot/ (a group: capture, + open-shot, manage, import-design, compare with its diff worker, overlay) entities/ page, settings, override, editor-tab (+ Monaco model registry, page tabs and whether they hold edits, read-only source tabs), resource, source-map (each bundle's map state, the originals tree), app-update (updater state, the bundled CHANGELOG.md), workspace (+ its rail tile), @@ -191,20 +208,25 @@ src/ rule (+ hit counts and recent requests, header presets, header change rows), action, network-request, held-request (what breakpoints hold now), page-stack (each frame's libraries), inspector (picking, what is under the pointer, the picked component and where its functions come from, - hook names, the Components tree, the Renders log and its profile, the Stores log) + hook names, the Components tree, the Renders log and its profile, the Stores log), browser (the + browsers, driven and everyday ones, their icons), shot (the shots and the overlay, their lines and + thumbnails) shared/ api (preload bridge), ui (design system), monaco (+ the JSON service and per-model schemas), lib (format and source-map workers, overlays, motion, JSON schemas inferred from a response; the source-map worker also reads hook names off originals), config shared/ (both processes, beside the renderer's) stackLibraries, stateAction/ (the code of an action that - sets a component's state: the Component page's Save as action, per framework) + sets a component's state: the Component page's Save as action, per framework), overlay/ (the + overlay settings' schema) test/ unit/ matcher, transform, stores, rule validation and matching, engine and PageInterception (fake CDP), - minified heuristic, source maps (finding, loading, header capture) + minified heuristic, source maps (finding, loading, header capture), browsers (finding, driving), + shots, BiDi answering, everyday Firefox tabs renderer/ resource tree building, palette fuzzy matching, session, workspaces, rules, original sources (parsing, positions through pretty-printing, tabs, jumps, tree), the Network panel, response tabs, held requests and breakpoints integration/ engine, rules, iframe and worker sessions against real Chromium + fixture site, source maps - in every form, the network log, response overrides, unsent answers and breakpoints + in every form, the network log, response overrides, unsent answers and breakpoints; captures, + the overlay, a driven Chromium, a driven Firefox (where Firefox is found) e2e/ the built Electron app driven by Playwright smoke/packaged.ts a packaged build (installed app) driven over the remote debugging port smoke/update.ts an installed app updated to a newer build from a local stand-in for GitHub @@ -216,7 +238,9 @@ test/ (networkPages.ts, /network/: JSON, GraphQL, an event stream, a failing call, a worker's fetch) fixtures/apps/ small React and Vue apps the inspector's tests bundle (the page stack's, a cart per framework, and carts whose state is in Redux Toolkit, Zustand, Pinia and Vuex, and NgRx) - helpers/ Chromium launcher with the app's flags, WebSocket CDP harness, bundleApp.ts (a fixture app + fixtures/firefox/ a session file Firefox wrote (two blank tabs), for the everyday tabs' reader + helpers/ PNG decode and encode, a `.jsonlz4` writer, stopping processes by command line, Chromium + launcher with the app's flags, WebSocket CDP harness, bundleApp.ts (a fixture app bundled with Vite: minified with a source map, or a development build; React, Vue 3 and 2, Angular (compiled in the page), Lit) build/ app icon (icon.png 1024 px original, icon.icns macOS, icon.ico Windows, icons/ Linux sizes), @@ -320,6 +344,31 @@ interface ConsoleAction { // code kept to run in a frame with one click: see createdAt: number; updatedAt: number; } // Each action belongs to one workspace (`workspaceId` in actions.json). + +interface Shot { // a capture of the page, or a design to check it against: see §6.17 + id: string; // 8 hex chars + kind: 'capture' | 'design'; + name: string; // shop.test-cart-1440-full.png; unique in its workspace, renamed in place + width: number; height: number; // pixels in the file + scale: number; // pixels per CSS pixel: 2 for a capture on a 2× screen, or a design exported at 2× + pageUrl: string | null; // a capture's address + browser: { id: string; name: string; version: string | null } | null; // a capture's browser ('app': the app's page) + viewport: { width: number; height: number } | null; // a capture's window, in CSS pixels + area: 'viewport' | 'page' | 'element' | null; + group: string | null; // captures taken together in every browser share one + createdAt: number; updatedAt: number; +} +// Each shot belongs to one workspace (`workspaceId` in shots.json); at most 500 per workspace, 50 MB per file. + +interface OverlaySettings { // the design over the page: see §6.18 (not kept on disk) + opacity: number; // 0–1 (0.5) + blend: 'normal' | 'difference'; + invert: boolean; + x: number; y: number; // where it sits, in CSS pixels + attached: 'page' | 'viewport'; // scrolls with the page, or stays put + hidden: boolean; + fitWidth: boolean; // the page laid out at the design's width (on) +} ``` Originals opened from source maps (§6.8) are tabs of their own kind, kept apart from file tabs as pages are: they are read-only, never saved or restored, and close with the workspace. @@ -332,6 +381,11 @@ rules.json { version: 1, rules: (Rule & { workspaceId })[] } files/. served content files/.base. diff base (only when it differs from the content) actions.json { version: 1, actions: (ConsoleAction & { workspaceId })[] } // every workspace's, oldest first +shots.json { version: 1, shots: (Shot & { workspaceId, ext })[] } +shots/. a shot's image, as captured or imported +shots/.thumb.jpg its thumbnail: the top of it, 256 px wide +browsers.json (in ) { version: 1, added: { id, name, path, engine }[], hidden: string[] } // Settings › Browsers (§6.16) +browsers// (in ) the profile of a browser the app drives (§6.16) settings.json (in ) Settings page-window.json (in ) { detached, bounds?, maximized? }: whether the website has its own window, and where (§7.1) actions-window.json (in ) { detached, bounds?, maximized?, onTop? }: the same for the Actions panel (§6.9) @@ -683,6 +737,60 @@ While store actions are recorded, every action the page's stores handle in every **UI.** The bottom pane's **Stores** tab (a dot while recording): the actions newest first (the last 1,000 kept, 200 drawn, only those in view drawn, each measured as it is), each with its number, time, frame, store (its Pinia id, its connection's name, or Redux), type, payload, how long the store took (Redux's reducers, Pinia's actions), and a link to the app's call that dispatched it; then what it changed, and the whole stack on request. **Record store actions** in the palette starts it. The page stack names Redux when a store was created through the stand-in. +### 6.16 Other browsers + +The page can be opened in the browsers installed on this computer, to check whether something is broken there. The research, and what is still to come, is in [BROWSERS_RESEARCH.md](BROWSERS_RESEARCH.md). + +**Finding them** (`src/main/browsers/findBrowsers/`), looked for at most once a minute and sorted by name, the ones the user added after them: +- **Linux:** desktop entries in `XDG_DATA_HOME`, Flatpak's exports (the user's and the system's), `XDG_DATA_DIRS` and Snap's, the first entry of an id winning. An entry counts when it is in the `WebBrowser` category, or handles `text/html` and http(s). Entries that are hidden, run in a terminal, or are the app itself are skipped, and its program (`TryExec`, or `Exec`'s) must be runnable. `Exec` is split as the spec says: `%u %U %f %F` mark where the address goes, other field codes are dropped. Icons come from the icon theme (`hicolor` and `pixmaps`), PNG scaled to 64 px or an SVG of at most 256 KB. +- **macOS:** the `.app` bundles in `/Applications` and then `~/Applications` whose name is a browser's the app knows (by engine), started with `open -a`; their icons from the system. +- **Windows:** the registry's `StartMenuInternet` clients (the machine's, the user's and 32-bit ones), each once, started by their `shell\open\command`; their icons from the system. +- **Engines** are told from the name and program (`chromium`, `gecko`, `webkit`, else `unknown`). **Versions** are read one at a time after a scan and announced once all are in: `CFBundleShortVersionString` on macOS, the file's product version on Windows, `--version` on Linux (through `flatpak run` for a Flatpak), with a 5-second limit each. +- **Added browsers:** Settings › Browsers adds a program (or a macOS app) picked with the system's dialog; switching a browser off there stops offering it. Both are kept in `browsers.json`. + +**Opening the page** (the browser menu beside the address bar, and the palette's **Open in …**) starts the browser's own command with the address, on its own (it outlives the app), with its everyday profile. Only http(s) pages are opened. The menu's button shows up to four browsers' icons; the menu searches them, and Enter opens the first match. + +**With your changes** (**Open in … with your changes**, for Chromium browsers and Firefox: `DRIVEN_ENGINES`) launches the browser with a profile of the app's own, and serves the active workspace's overrides and rules in every tab it has (`src/main/browsers/driven/`): +- **The profile** is `/browsers/`; for a Snap it is `~/snap//common/console-editor-profile`, and for a Flatpak `~/.var/app//data/console-editor-profile`, the folders their sandboxes let them write. Logins stay there between launches. Chrome 136 and later ignore a debugging port on the everyday profile, which is one more reason for a profile of its own. +- **Launching:** Chromium gets `--user-data-dir`, `--remote-debugging-port=0`, `--no-first-run`, `--no-default-browser-check` and the app's own Local Network Access switches, and writes the port in `DevToolsActivePort`. Firefox gets `--profile`, `--no-remote`, `--new-instance` and `--remote-debugging-port=0`, with a `user.js` that skips its first-run pages, the default-browser question and data reporting, and writes its WebDriver BiDi address in `WebDriverBiDiServer.json`. On macOS a new instance is started (`open -n -a … --args`); for a Flatpak the flags go before its forwarded-file markers. Both start on a blank page. +- **Reaching it again:** a browser still open with that profile (the app was restarted, or let go of it) is reached through the address in its profile rather than launched twice: a second launch would hand its address to the running browser and never open a port. +- **Chromium** is driven over CDP: a browser-level connection auto-attaches every tab (a new one waits before it loads anything) and gives each one a `PageInterception` of its own, the engine the app's page uses (§6.1–§6.6). Other targets are let go of as they came. +- **Firefox** is driven over WebDriver BiDi (`src/main/engine/bidi/`): one interception for every tab pauses requests while an override or a rule is on, and bypasses the cache then. Before a request is sent, a block rule fails it, a CORS preflight ahead of a request an override answers is allowed, and an override answers it (`src/main/engine/answering/`). Firefox can only replace a body at that point, so the override's headers are its kind's (content type, `no-store`), a response override's status, header changes and delay, readable cross-origin, then the header rules. At the response's head, header and CORS rules change it. What this leaves out in Firefox: patch mode (the saved text is served), redeploy detection, an override naming a GraphQL operation (BiDi gives no request body), an overridden script behind an `integrity` attribute unless the page's HTML is overridden too, and breakpoints. +- **Keeping them in step:** overrides, rules and settings that change reach the driven tabs at once, and with **Reload page after changes** on, their tabs showing a website reload (after a burst, once). Nothing is reported from them: the console, Network panel and inspector stay the app page's. +- **Tabs:** the menu lists each driven browser's tabs, with their titles (read when a page has loaded and whenever the menu opens, since neither protocol announces a title) and addresses. A tab can be brought to the front, loaded in the app, or captured. The × lets go of the browser, which stays open. A browser the user quits is forgotten, and the status bar counts the tabs served your changes. + +**Your everyday Firefox's tabs** (`src/main/browsers/everyday/`): with Firefox installed, the menu offers **Your Firefox tabs**. Once asked, and then each time the menu opens in that run, it reads every Firefox profile's session file (`sessionstore-backups/recovery.jsonlz4`, which Firefox keeps up to date as it runs): `profiles.ini` in Firefox's folder on each system (Snap and Flatpak ones too) names the profiles, the install's default first. The file is an LZ4 block behind a `mozLz40` header, decoded by the app's own reader. Each tab's current page is listed when it is on the web, and choosing one loads it here. + +### 6.17 Captures and designs + +A workspace keeps **shots**: captures of the page, and designs to check it against (`ShotStore`, `PageShots`). + +**Capturing** (the shots menu's **Capture**: what the page shows, the whole page, an element, in every browser; the palette; a component's page) goes over CDP (`src/main/shots/capture/`): +- Waits up to 2 s for the page's view to be on screen (a menu over it shows a still), and lays the design aside while it captures (§6.18). +- **What the page shows:** `Page.captureScreenshot`, scrollbars included. +- **The whole page:** the document rendered past the viewport (`captureBeyondViewport`, clipped to its size), which leaves the viewport and scroll as they were, down to Chromium's largest texture (16 384 device pixels tall). +- **An element:** the inspector's pick (§6.13), its border box (`DOM.getBoxModel`) moved by each cross-site frame's place in its parent (`DOM.getFrameOwner`, asked of the session that has it) and by the scroll, without the inspector's highlight. +- A capture's **scale** is its pixels per CSS pixel, and its viewport the window's size in CSS pixels (`innerWidth`, `innerHeight`, read in the page). It is named after its address, viewport width and what it covers (`shop.test-cart-1440-full.png`), and gets a 256 px JPEG thumbnail of its top. +- A **driven browser's tab** is captured the same way over its own protocol (brought to the front first, since a hidden tab isn't drawn), with the browser's name and version. + +**In every browser** captures the whole page in the app, then in every driven browser at once at the same address, in the tab showing it (or one opened there). Each is laid out at the app's viewport and density (`Emulation.setDeviceMetricsOverride`, BiDi's `setViewport`) and captured once it has loaded and been quiet for 500 ms (30 s at most). The captures are kept as one **group**; a browser that fails is named in the notice. The group's page (an editor tab) shows them side by side against a **baseline** (the app's capture at first, another of them, or a design), each with the share of its pixels that differ, or the difference itself. Choosing one opens the two in the compare page. + +**Designs** are imported with the system's dialog, dropped or pasted on the shots menu: PNG, JPEG or WebP of at most 50 MB, told by their bytes, kept as they came. A design's scale comes from `@2x`/`@3x` in its name, else 2 when it is wider than 2000 px, else 1, and can be changed on its page. + +**The shots menu** (the toolbar's end, in both windows: the latest thumbnails stacked) searches shots by name, address and browser, with All · Captures · Designs. A shot's menu copies it, saves a copy, shows its file, lays it over the page, and deletes it (in the editor). **A shot's page** shows it at fit, 100 % or zoomed in (a pixel grid from 8×), the colour under the pointer, its size and where it came from, and renames it in place. + +**Comparing** (a shot's **Compare with…**: another shot, or the page now captured at the shot's width and scale) opens the compare page: side by side (scrolled together), swipe, onion skin, or the difference. The difference is worked out in a worker: both are drawn at their CSS size; pixels whose YIQ distance is over the threshold, or that only one covers, differ. They are shown red over the faded base, with the share of pixels that differ, and the areas that differ (16 px cells joined, at most 200) to step through. The top image can be moved (Shift for 10 px) to line them up. + +Images are served to the UI by the `console-editor-shot:` protocol, on the app's own session only (`image/` and `thumb/`); their pixels are read over IPC, since the protocol's images would taint a canvas. + +### 6.18 The design over the page + +**Put over the page** (a shot's page and menu) lays a design over the app's live page (`src/main/overlay/`), with a bar under the toolbar (both windows): opacity, **Difference**, x/y (the arrows while the bar has focus, Shift for 10 px), scrolling with the page or fixed, the page laid out at the design's width, Hide and Take off. +- The design is drawn on a canvas by a script in an isolated world (`Page.addScriptToEvaluateOnNewDocument` with a world name, and `Page.createIsolatedWorld` of that name for the document already there), so a strict CSP can't block it and the page's scripts can't reach its code. It is put back if the page removes it, and follows every load of the page. It never takes the pointer. +- **Difference** needs a backdrop: a page with no background of its own is given a white one while it blends, and gets its own back afterwards. +- **At the design's width:** the page is laid out that many CSS pixels wide and scaled down to fit the view, as DevTools' device mode does (Electron's `enableDeviceEmulation`: CDP's scale draws wrong in a `WebContentsView`), again as the view resizes. +- Captures lay it aside and give the page its own width while they run. It goes when its shot is deleted or another workspace is shown. + ## 7. Editor behaviour | Action | Behaviour | @@ -745,6 +853,10 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g - The store stand-in puts the Redux DevTools extension's globals in pages that have none (only while **Framework hooks** is on): a page can tell, and a store made through them keeps working when nothing records (the stand-in's dispatch only notes the action). Its own global (`__consoleEditorStoreHook`) only finds a frame's Vue stores and says which libraries it heard of. Store names, action types, payload previews and stacks are checked like any answer; a stack's calls must be in http(s) files. - An action saved from the Component page is code the app wrote, shown in the action form before it is kept: the page's selectors are in it as JSON strings, and the value as the JSON you typed. - Rules change only what the app's own browser sees. A CORS rule makes an API readable by the page shown, credentials included, which is what it is for; it can't change what the server allows, and cookies still follow the browser's rules. +- Other browsers are started as the user's own programs, found where the system lists them or picked by the user, with an http(s) address only. A browser driven with your changes has a profile of the app's own (never the everyday one) and a debugging port on `127.0.0.1` that the system picks; like any debugging port, a local program can connect to it while the browser runs. The app reaches it only through the address that browser wrote in that profile, and lets go of it (the browser stays open) when asked or when the app quits. +- A driven browser's tabs are served the workspace's overrides and rules, as the app's page is; nothing is read back from them but their titles, addresses and captures. +- Your everyday Firefox's session file (it holds your open tabs, and more) is read only after you ask for its tabs in the browser menu, in the main process; only each tab's current http(s) address and title reach the UI, and nothing of it is kept or sent anywhere. +- Shots are images the app took or the user imported (checked by their bytes), served to its own UI by the `console-editor-shot:` protocol on its own session only; the site's session can't load them. The design laid over the page lives in an isolated world the page's scripts can't reach, and takes no pointer events; the page can still see the element in its DOM, and it is put back if removed. - The site sees a standard Chrome user agent (Electron tokens removed). - All data stays local: nothing is uploaded, and there is no telemetry. Besides the page and out-of-page fetches for the files you open and for the page's favicon (from the site shown, through its session; kept only if its bytes are an image, and shown as an `` data URL, where SVG can't run scripts), the source maps of the page's scripts and stylesheets when you ask for them (§6.8: http(s) through the site's session, with cookies only for the bundle's or the page's origin; `data:` maps decoded locally; any other scheme refused; 64 MB, 30 s), the only other network calls are the update check (GitHub's releases API and the release's CHANGELOG.md at its tag) and, when you ask for it, the update's download. **Settings › Check for updates** turns the automatic check off. - Updates are verified before they are installed: electron-updater checks the SHA-512 in the release's `latest*.yml`, and manual downloads are checked against the release's `SHA256SUMS.txt` before they are kept. Both come from the same GitHub release, so this guards against damaged downloads, not a compromised release; signed builds would add that (§11). Release notes are Markdown rendered with `marked` and sanitized with DOMPurify (no images, styles, forms or frames), and their links open in the default browser (http and https only). `CONSOLE_EDITOR_UPDATE_FEED` (a local update server, for tests) is honoured only with a data folder of its own, like the debugging port. @@ -757,8 +869,8 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g | Renderer | Resource tree building and filtering (worker entries: kept across navigations for service and shared workers, dropped with their worker, filtered by worker URL), command-palette fuzzy matching, missed-override notices by reason (a nested worker's once per override version), update notifications, What's New and page tabs, session sync and workspace switching (pending drafts written, tabs closed without losing them, the other workspace's reopened), console frames (keys, labels, colours), rows, filters, Keep rows, prompt history and frame names, rules (quick rules and Undo, toggling with rollback, rule pages and their forms, applying only what changed, results that arrive after a switch, hits and recent requests), original sources (parsing, positions through pretty-printing, tabs, jumps both ways, the tree, the palette), actions (finding an action's frame by key or by name, labels of frames not on the page, runs and why one couldn't run, the form's openings, saving and deleting, copies, the palette's group, the bridge loading and following them), the Actions window's bridge (what it loads, the events it follows and those it ignores) and the editor's copy of where the panel is and of settings another window changed, the Network panel (rows kept and replaced, earlier loads dropped unless kept, groups and the text filter, status, size and time cells, which actions a request offers) and response overrides (the inferred schema, which override answers a request, opening a response and saving it with its method, operation and answer, the response row's fields and Apply), the bottom pane's tab, held requests (`held.test.ts`: their tabs, what Send does at each stage and with an edited body, fields that can't go out, Send original, Fail, Save as override, closing the tab, the page giving up, never kept in the session), breakpoints (added, toggled, removed, refused, put back when saving fails, Pause like this) and Copy as fetch, quick edits (`quickEdits.test.ts`: lists emptied, texts lengthened but links, ids and dates, the value at the cursor nulled, the rest of the text as typed) and Patch live through the response row's form, the response tree (`responseTree.test.ts`: rows for what is open with unique ids, members removed with their comma, added as the last one is written, the selection after a rename or removal), the page stack (UI libraries named once, the top page's first; findings by kind; one Page stack tab; the bridge's snapshot and `stack-changed`), the component inspector (`componentInspector.test.ts`: a function's place in the bundle mapped to its original, named by the map or read off the original's text in each way code names a function; a React component named after its original, a Vue one by itself; picking toggled as main last said; another component of the chain read, or a toast when the page moved on; the bridge following picking and showing what was picked, in one Component tab renamed in place); hook names read off an original (`hookNames.test.ts`: hook calls laid out as React keeps them, same-file custom hooks followed, an imported one stopping the naming, arrows placed by their parameters); renders, the tree and state (`componentRenders.test.ts`: why a component took part, in words, hooks named; triggers; what a frame's last commit rendered; the log's cap; the bridge following recording and tracing functions once each; tree rows as far as open; a frame's top level, a node opened once and closed, a stale frame's level dropped, a pick revealed down its path; opening a node, and a toast when it can't; setting a value, and a toast when it can't); maps loaded from files (`mapFiles.test.ts`: location keys read back; a bundle's traced places forgotten and traced again once a map is loaded or forgotten, every original and hook name forgotten with the maps and a lookup finishing after dropped, nothing when no file was picked, toasts on failures; the Explorer's menu items); store actions (`storeTimeline.test.ts`: recording followed, actions kept and capped, their calls traced once each, a failure to start said; the app's own call in a stack, past libraries); requests by component (`requestsByComponent.test.ts`: a frame's requests sent through a file, newest first, with the call); a commit's trigger with its action, a set value kept and the action written from it per framework (a hook's place, a class's key, selectors through shadow roots, none without a way in), renders by component (counts, own time summed, reasons, costliest first; kept as commits come and go the same as summed afresh; the log's rows) (`componentRenders.test.ts`) | `npm test` | | Performance | The inspector on large apps in real Chromium (`test/perf/inspector.perf.test.ts`): a React and a Vue 3 app of some 14,000 components (a 2,000-row table, a chain 1,500 deep in React, 300 in Vue) and stores of 20,000 items (Redux Toolkit, Pinia), as production builds. It times a commit of every row with the hooks off, idle and recording renders, and after picking; hovering and picking deep in the table and at the end of the chain; the page stack; a level of the tree; a batch's size; Redux dispatches and Pinia actions idle, recording and after it stopped. And the built app's UI (`test/perf/ui.perf.test.ts`) fed what large apps record at the pace recording sends it (2,000 commits of 200 components, 1,000 store actions with 50-call stacks, 2,000 requests with 20-call initiators, their places traced through a real bundle's map): how long the window's main thread is blocked, its longest task, the memory the log takes, and how long **By component** takes to show. Each prints its medians and fails past a budget set well above what a laptop measures, so only work growing with the whole page on a hot path trips it; it isn't part of CI | `npm run test:perf` (on a display: `xvfb-run -a` on headless Linux) | | Architecture | Feature-Sliced Design layer rules; the code-structure rules (files of at most 150 lines, one function, component, class or store per file named after it, data-only constants/types/index files, no `switch`, no sibling names that differ only in case) | `npm run lint:fsd`, `npm run lint:structure` | -| Integration | Engine in real Chromium against the fixture site: gzip, static and runtime SRI, globs, CSS/HTML overrides, 404, redeploy detection, source maps, disable. Iframes through the session-aware WebSocket transport (`test/helpers/chromium.ts`): same-site, cross-site and nested iframes, SRI inside iframes, iframe HTML overrides, same-site navigation, removal, reload; each asserts the iframe really is a separate target. The console on a page of service iframes (same-site, and two on sites of their own): every frame's first log line on its own frame, code run in one frame and another frame's logs reacting, top-level `await` and expanding the result, uncaught errors and rejections, a frame keeping its id across a navigation, a cross-site frame's function refused from the top page but run in the frame itself (why actions run in their frame). Workers against the fixture's `/workers/` page (`workers.chromium.test.ts`, a fresh browser context per test): every kind runs while intercepted; edits reach a dedicated and a module worker's first script and imports, what a nested worker imports (its first script is served in Chromium 141, reported as missed in 152+), a shared worker, a service worker's script and imports, and an audio worklet's module; each file is listed with its worker and read through its session; a shared worker's races are forced by holding back its session's commands; workers under a cross-site iframe are served on its session and removed with it; workers are reported gone; an edit to an installed service worker applies on the next reload, and one whose page was left is reinstalled when its site loads again under other overrides (a workspace switch); the page's `registration.update()` is reported and the next reload undoes it; a service worker is listed again, and not reinstalled, when the page comes back to its site; the cache setting reaches what workers load. Rules (`rules.chromium.test.ts`): blocking before the server sees the request (a script, one inside a cross-site iframe, an iframe's document, a redirect's later hop), a URL both blocked and overridden, the page itself never blocked; a gzipped document's CSP removed and another's added, X-Frame-Options removed so a page can be framed, a document no rule changes never read, Cache-Control and Content-Type on streamed responses, headers back once a rule is off; CORS with credentials, a preflight the API refuses, redirects. Source maps named by headers, `X-SourceMap`, comments and `data:` URIs, a stylesheet's, and an override-served bundle's. The network log and response overrides (`network.chromium.test.ts`, the fixture's `/network/` page): what the log lists for fetch(), GraphQL, a failing call, an event stream and a worker's fetch, page loads counted, request and response bodies read, never a stream's (which keeps streaming); a response override answering fetch() and XHR with its body, status and header changes (the request still reaching the server) and marking the row, its delay, only its method and GraphQL operation, a dedicated worker's fetch, and an event stream left streaming or replaced as a whole. Held and unsent requests (`held.chromium.test.ts`): an override that doesn't send answering another origin's POST and its preflight with the server never hit (and, without it, the server turning the preflight away); breakpoints holding a response (its row marked) until answered edited or as it was, a request before it is sent (its row marked) sent elsewhere or answered, one failed, one the page gave up on let go, everything let go when interception stops, and nothing stopped once off. In `network.chromium.test.ts` too: patch mode keeping the live values it wasn't told to change and answering a failing upstream with the saved text; the page and its worker taken offline and slowed down; a WebSocket's handshake, text and binary messages read incrementally, and its close; the log written as HAR with its bodies and read back as overrides that answer without the server. The page stack (`stack.chromium.test.ts`): a React app the test bundles from the repo's react and react-dom, found through the hook stand-in with its version, as a production and a development build, and by its keys with **Framework hooks** off (and no hook left in the page); what Vue, Pinia, Angular, Next.js and webpack leave in a page; a cross-site iframe's React, which found the stand-in in place before it loaded; no console rows. The component inspector (`inspect.chromium.test.ts`), with real pointer input on carts in React and Vue the test bundles (`test/helpers/bundleApp.ts`), as minified production builds with source maps and as development builds: a production React component's chain, props, state, context and handler, each function traced through the map to its source line; a click the page never gets, and the element as `$0`; the chain's other components, and an element that is gone; a production Vue component through its app's vnode tree, with what the app provides, and a development build's from the element; picking inside a cross-site iframe on its own session; Esc in the page; a useState hook set and rendered, JSON and unsettable values refused; Vue's data and a setup ref set, a computed one refused; the Components tree of a React app through the hook stand-in and by its containers without it, and of a production Vue app (each function placed once however many of its components a level lists), nodes opened as picks at their depth, picks' paths; renders recorded on a React app whose buttons each commit for another reason (own state and a store, a context with a memo child skipped, a parent's state with changed props, a class's state), with triggers, frames, durations and order, nothing after stopping, and a production build's mount recorded from a document loaded while recording, its functions placed through the map. The page stack (`stack.chromium.test.ts`) also counts a frame's scripts that name a source map by comment or header. The other frameworks (`frameworks.chromium.test.ts`), each bundled by the test from the repo's own packages: a production Angular component read through its view registry (found once, then taken from the page without walking the heap again; inputs, a settable signal, a field that isn't, each class placed by the map; hosts named by their tags while hovering), a development build through `window.ng` and its tree, a production build's tree through the registry; a Vue 2 component's props, data (set), what the app provides, chain and tree; a Lit component in its shadow root, its properties and state (set), host chain and tree; a plain page's listeners with names, places and options; a Vue 3 listener's function, not Vue's invoker; store actions (`stores.chromium.test.ts`): Redux Toolkit's (the slice's type, payload, what changed, the handler's line through the map; the page stack names Redux), the React commit after one naming it and nothing once stopped, Zustand's through its devtools, Pinia's actions and direct changes (heard again once an action that never settles stops counting) and Vuex's mutations (found when recording starts and by a document loaded while recording), NgRx's through its StoreDevtools; a request keeping the stack of the handler that sent it; saved actions (`stateActions.chromium.test.ts`): a picked component's action written, the page loaded again, and the action setting the value in React (a hook), Vue 3 production and development (a ref and data), Vue 2 (data), Angular development (a signal; production gets none) and Lit (through two shadow roots) | `npm test` (skips if no Chromium; `npx playwright install chromium`) | -| End-to-end | Built Electron app driven by Playwright: on Linux, the editor's title bar in place of the system's (the window buttons clear of its controls, the bar a drag area and its controls not), open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette. The Network panel (`network.e2e.test.ts`): the page's fetch and GraphQL calls listed, Fetch/XHR first and the document under All; a response opened, edited, saved and served to the page, the row marked and the override in the Explorer with its method; another status applied from the response row; the override turned off; requests like the selected one paused and an edited response sent to the page, a paused response saved as an override that answers from then on, a breakpoint written in the menu holding a request before it is sent, and that request failed; the live response patched from the response row, a quick edit saved and served, the page taken offline and back from the network speed menu, a WebSocket's messages in its details, a response edited as a tree with the keyboard (a value retyped, a key renamed, a member removed) and saved, and the requests exported as HAR and imported back as overrides. The page stack (`stack.e2e.test.ts`): the status bar names the fixture's UI libraries and opens the Page stack, which lists each finding by kind with its version, build and evidence, and scans again. The component inspector (`inspect.e2e.test.ts`) on a production React cart framed from another site: the toolbar's button picks and the Inspect view shows what is under the pointer; a click opens the Component page, named `CartItem` and placed at its source line through the map, with props, state, context, the handler by its original name and the chain; **Open original** and **Go to bundle code** land on its definition; the View menu starts and stops picking, and Esc in the editor stops it; the Inspect view's tree lists the cart frame's components down to the one picked, selected; a state value named `qty` (from the original) is set from the Component page and the cart shows it; **Record renders** from the palette records a real click, the Renders tab says it was a click on `button#add-A1` and that `CartItem` rendered for `state qty 5 → 6`, and the Component page lists that render. A map from a file (`mapFile.e2e.test.ts`): a production React bundle that names no map, picked, given its map through **Load a source map…** (the system's dialog stubbed in the main process) and named `CartItem` at its line, then forgotten; a plain page's element shown with its listener and where it is defined; the inspected cart's renders by component, and its set value kept as an action that sets it again after a reload; a Redux Toolkit cart (`dataFlow.e2e.test.ts`): its actions recorded from the palette with what they changed and the line that dispatched them (and their stack), the commit after one naming it, and a request's **Sent by** stack traced to the handler | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) | +| Integration | Engine in real Chromium against the fixture site: gzip, static and runtime SRI, globs, CSS/HTML overrides, 404, redeploy detection, source maps, disable. Iframes through the session-aware WebSocket transport (`test/helpers/chromium.ts`): same-site, cross-site and nested iframes, SRI inside iframes, iframe HTML overrides, same-site navigation, removal, reload; each asserts the iframe really is a separate target. The console on a page of service iframes (same-site, and two on sites of their own): every frame's first log line on its own frame, code run in one frame and another frame's logs reacting, top-level `await` and expanding the result, uncaught errors and rejections, a frame keeping its id across a navigation, a cross-site frame's function refused from the top page but run in the frame itself (why actions run in their frame). Workers against the fixture's `/workers/` page (`workers.chromium.test.ts`, a fresh browser context per test): every kind runs while intercepted; edits reach a dedicated and a module worker's first script and imports, what a nested worker imports (its first script is served in Chromium 141, reported as missed in 152+), a shared worker, a service worker's script and imports, and an audio worklet's module; each file is listed with its worker and read through its session; a shared worker's races are forced by holding back its session's commands; workers under a cross-site iframe are served on its session and removed with it; workers are reported gone; an edit to an installed service worker applies on the next reload, and one whose page was left is reinstalled when its site loads again under other overrides (a workspace switch); the page's `registration.update()` is reported and the next reload undoes it; a service worker is listed again, and not reinstalled, when the page comes back to its site; the cache setting reaches what workers load. Rules (`rules.chromium.test.ts`): blocking before the server sees the request (a script, one inside a cross-site iframe, an iframe's document, a redirect's later hop), a URL both blocked and overridden, the page itself never blocked; a gzipped document's CSP removed and another's added, X-Frame-Options removed so a page can be framed, a document no rule changes never read, Cache-Control and Content-Type on streamed responses, headers back once a rule is off; CORS with credentials, a preflight the API refuses, redirects. Source maps named by headers, `X-SourceMap`, comments and `data:` URIs, a stylesheet's, and an override-served bundle's. The network log and response overrides (`network.chromium.test.ts`, the fixture's `/network/` page): what the log lists for fetch(), GraphQL, a failing call, an event stream and a worker's fetch, page loads counted, request and response bodies read, never a stream's (which keeps streaming); a response override answering fetch() and XHR with its body, status and header changes (the request still reaching the server) and marking the row, its delay, only its method and GraphQL operation, a dedicated worker's fetch, and an event stream left streaming or replaced as a whole. Held and unsent requests (`held.chromium.test.ts`): an override that doesn't send answering another origin's POST and its preflight with the server never hit (and, without it, the server turning the preflight away); breakpoints holding a response (its row marked) until answered edited or as it was, a request before it is sent (its row marked) sent elsewhere or answered, one failed, one the page gave up on let go, everything let go when interception stops, and nothing stopped once off. In `network.chromium.test.ts` too: patch mode keeping the live values it wasn't told to change and answering a failing upstream with the saved text; the page and its worker taken offline and slowed down; a WebSocket's handshake, text and binary messages read incrementally, and its close; the log written as HAR with its bodies and read back as overrides that answer without the server. The page stack (`stack.chromium.test.ts`): a React app the test bundles from the repo's react and react-dom, found through the hook stand-in with its version, as a production and a development build, and by its keys with **Framework hooks** off (and no hook left in the page); what Vue, Pinia, Angular, Next.js and webpack leave in a page; a cross-site iframe's React, which found the stand-in in place before it loaded; no console rows. The component inspector (`inspect.chromium.test.ts`), with real pointer input on carts in React and Vue the test bundles (`test/helpers/bundleApp.ts`), as minified production builds with source maps and as development builds: a production React component's chain, props, state, context and handler, each function traced through the map to its source line; a click the page never gets, and the element as `$0`; the chain's other components, and an element that is gone; a production Vue component through its app's vnode tree, with what the app provides, and a development build's from the element; picking inside a cross-site iframe on its own session; Esc in the page; a useState hook set and rendered, JSON and unsettable values refused; Vue's data and a setup ref set, a computed one refused; the Components tree of a React app through the hook stand-in and by its containers without it, and of a production Vue app (each function placed once however many of its components a level lists), nodes opened as picks at their depth, picks' paths; renders recorded on a React app whose buttons each commit for another reason (own state and a store, a context with a memo child skipped, a parent's state with changed props, a class's state), with triggers, frames, durations and order, nothing after stopping, and a production build's mount recorded from a document loaded while recording, its functions placed through the map. The page stack (`stack.chromium.test.ts`) also counts a frame's scripts that name a source map by comment or header. The other frameworks (`frameworks.chromium.test.ts`), each bundled by the test from the repo's own packages: a production Angular component read through its view registry (found once, then taken from the page without walking the heap again; inputs, a settable signal, a field that isn't, each class placed by the map; hosts named by their tags while hovering), a development build through `window.ng` and its tree, a production build's tree through the registry; a Vue 2 component's props, data (set), what the app provides, chain and tree; a Lit component in its shadow root, its properties and state (set), host chain and tree; a plain page's listeners with names, places and options; a Vue 3 listener's function, not Vue's invoker; store actions (`stores.chromium.test.ts`): Redux Toolkit's (the slice's type, payload, what changed, the handler's line through the map; the page stack names Redux), the React commit after one naming it and nothing once stopped, Zustand's through its devtools, Pinia's actions and direct changes (heard again once an action that never settles stops counting) and Vuex's mutations (found when recording starts and by a document loaded while recording), NgRx's through its StoreDevtools; a request keeping the stack of the handler that sent it; saved actions (`stateActions.chromium.test.ts`): a picked component's action written, the page loaded again, and the action setting the value in React (a hook), Vue 3 production and development (a ref and data), Vue 2 (data), Angular development (a signal; production gets none) and Lit (through two shadow roots). Captures (the viewport, the whole page past it, an element in a cross-site frame, pixel-exact), the design over the page (a strict CSP, difference, after a reload, put back), a Chromium browser driven with your changes (launched, served, reloaded after a change, captured, reached again, forgotten once quit) and Firefox the same way over BiDi, with rules and a CORS preflight (where Firefox is found: `FIREFOX_PATH` or on the PATH) | `npm test` (skips if no Chromium; `npx playwright install chromium`) | +| End-to-end | Built Electron app driven by Playwright: on Linux, the editor's title bar in place of the system's (the window buttons clear of its controls, the bar a drag area and its controls not), open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette. The Network panel (`network.e2e.test.ts`): the page's fetch and GraphQL calls listed, Fetch/XHR first and the document under All; a response opened, edited, saved and served to the page, the row marked and the override in the Explorer with its method; another status applied from the response row; the override turned off; requests like the selected one paused and an edited response sent to the page, a paused response saved as an override that answers from then on, a breakpoint written in the menu holding a request before it is sent, and that request failed; the live response patched from the response row, a quick edit saved and served, the page taken offline and back from the network speed menu, a WebSocket's messages in its details, a response edited as a tree with the keyboard (a value retyped, a key renamed, a member removed) and saved, and the requests exported as HAR and imported back as overrides. The page stack (`stack.e2e.test.ts`): the status bar names the fixture's UI libraries and opens the Page stack, which lists each finding by kind with its version, build and evidence, and scans again. The component inspector (`inspect.e2e.test.ts`) on a production React cart framed from another site: the toolbar's button picks and the Inspect view shows what is under the pointer; a click opens the Component page, named `CartItem` and placed at its source line through the map, with props, state, context, the handler by its original name and the chain; **Open original** and **Go to bundle code** land on its definition; the View menu starts and stops picking, and Esc in the editor stops it; the Inspect view's tree lists the cart frame's components down to the one picked, selected; a state value named `qty` (from the original) is set from the Component page and the cart shows it; **Record renders** from the palette records a real click, the Renders tab says it was a click on `button#add-A1` and that `CartItem` rendered for `state qty 5 → 6`, and the Component page lists that render. A map from a file (`mapFile.e2e.test.ts`): a production React bundle that names no map, picked, given its map through **Load a source map…** (the system's dialog stubbed in the main process) and named `CartItem` at its line, then forgotten; a plain page's element shown with its listener and where it is defined; the inspected cart's renders by component, and its set value kept as an action that sets it again after a reload; a Redux Toolkit cart (`dataFlow.e2e.test.ts`): its actions recorded from the palette with what they changed and the line that dispatched them (and their stack), the commit after one naming it, and a request's **Sent by** stack traced to the handler. Other browsers on Linux (a fake launcher found, opened, turned off, added and removed), captures (the shots menu, a capture's page, an element in a cross-site frame, per workspace), designs (imported, compared: 41.9 % in two areas, laid over the page), a real Chromium launcher opened with your changes, its tab captured and the page captured in every browser as a group, the same with Firefox where it is found, and your everyday Firefox's tabs read from a session file | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) | | Packaged | The installed app (asar, fuses, signature) fixes the demo store's checkout through the UI, driven over `--remote-debugging-port` since the fuses disable Node's inspector. The release workflow runs it on six runners, one per architecture: macOS (from the disk image), Windows (after a silent install; the x64 runner also checks that the ARM installer refuses it) and Linux (from the installed `.deb`, with Ubuntu's user-namespace restriction left on, also checking that the desktop entry and icons are the package's and the app added none of its own) | `npm run test:packaged -- ` | | Update | An installed app updates itself to a build one patch higher, served by a local stand-in for GitHub: the notification, What's New, the download, **Restart to update**, the restarted app running the new version (and What's New after it); an AppImage's desktop entry starts the old file before and the renamed one after. The release workflow runs it for the Windows installers (then uninstalls, checking the updater's cache goes too) and the AppImages on their four runners. The `.deb` path (as root, through `sudo`, and with the password refused) and the AppImage installing on quit were checked by hand | `npm run test:update -- ` | @@ -809,7 +921,7 @@ The package manager is asked rather than electron-builder's `resources/package-t - Docked/undocked page view, and responsive device presets. **M3: Your own Chrome, and distribution** -- External Chrome mode: launch Chrome with a dedicated `--user-data-dir` plus `--remote-debugging-port`, or connect to a running one; `WebSocketTransport` implementing `CdpTransport`; one engine per tab. Planned as phase 5 of M5 ([research](BROWSERS_RESEARCH.md)). +- ✅ External Chrome mode: a Chromium browser launched with a profile of the app's own and a debugging port, one engine per tab over a browser-level WebSocket connection (§6.16, M5). Still to come: your everyday Chrome (U14), which needs its own permission from Chrome 144's `chrome://inspect`. - ✅ Installers with electron-builder, built and smoke-tested on all three systems by the release workflow (§10). - ✅ Update notifications, What's New, and installing updates on Windows and with the AppImage, `.deb` and `.rpm` (§10.1). - Signed and notarized builds, and with them installing updates in place on macOS. @@ -821,13 +933,14 @@ The package manager is asked rather than electron-builder's `resources/package-t - Component inspector ([research](INSPECTOR_RESEARCH.md)): ✅ the page stack, which UI library, framework, state library and bundler each frame runs (§6.12); ✅ the React or Vue component behind an element, its original file (production builds too, through source maps), props, state, context, handlers and chain (§6.13); ✅ the Components tree, setting state, hook names from originals and source-map coverage (§6.12, §6.13); ✅ Renders, why each React component rendered (§6.14). ✅ Angular (development and production builds), Vue 2 and web components, the listeners on an element, and **Load a source map…** for a bundle whose map isn't published (§6.8, §6.13). ✅ Store actions (Redux, Redux Toolkit, NgRx, Zustand, Pinia, Vuex) with what each changed and the line that dispatched it (§6.15); who sent a request, and a component's requests (§6.10, §6.13); a state value kept as an action (§6.13); renders by component (§6.14). Still to come: Vue's renders, a data-flow view (what comes into a component and what it sets or calls), and async stacks for requests and actions. **M5: Other browsers, captures and designs** ([research](BROWSERS_RESEARCH.md)) -- Open the page in another installed browser: found per system, with their own icons, in a menu beside the address bar and in the palette. -- Captures of the viewport, the whole page or one element, kept per workspace and listed in a menu at the end of the toolbar, with search and Captures/Designs. -- Designs imported from files or the clipboard, and a compare page: side by side, swipe, onion skin and difference, with the share of pixels that differ. -- A design laid over the live page (a canvas in an isolated world, so a strict CSP doesn't block it), at the design's width. -- Chromium browsers with your overrides and rules (M3's external Chrome), and their tabs in the menu. -- One capture in every browser, compared in a grid. -- Firefox with your changes over WebDriver BiDi, and WebKit through Playwright's build. +- ✅ Open the page in another installed browser: found per system, with their own icons, in a menu beside the address bar and in the palette (§6.16). +- ✅ Captures of the viewport, the whole page or one element, kept per workspace and listed in a menu at the end of the toolbar, with search and Captures/Designs (§6.17). +- ✅ Designs imported from files or the clipboard, and a compare page: side by side, swipe, onion skin and difference, with the share of pixels that differ (§6.17). +- ✅ A design laid over the live page (a canvas in an isolated world, so a strict CSP doesn't block it), at the design's width (§6.18). +- ✅ Chromium browsers with your overrides and rules (M3's external Chrome), and their tabs in the menu (§6.16). +- ✅ One capture in every browser, compared in a grid against a baseline (§6.17). +- ✅ Firefox with your changes over WebDriver BiDi (§6.16), and your everyday Firefox's tabs in the menu. +- Still to come ([research](BROWSERS_RESEARCH.md), phases 8 and 9): WebKit through Playwright's build (it needs `playwright-core` in the packaged app, and a machine that can download the build to check it); the design over a driven browser's tab; tabs of other everyday browsers (AppleScript on macOS); Figma frames by link; full pages taller than 16 384 device pixels; the console, Network and inspector for an outside tab. ## 12. Risks and open questions From 2fb2b6afecdbdc90e0b7c51c6a1a0fb184d4c896 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Tue, 29 Sep 2026 09:29:15 +0000 Subject: [PATCH 11/19] Lay the design over the tabs of browsers opened with your changes A design put over the app's page now goes over every tab of the browsers driven with the workspace's changes too, including tabs opened later. It is restyled with it, laid out at its width while that is on, and taken off with it. A capture in those tabs takes it off while it runs. Chromium tabs get the app page's own overlay code over their CDP session. Firefox gets it as preload scripts in a sandbox of every new document, and in each tab's current one. The overlay's script now only draws in a top document. The app's own page ran it in same-site frames' documents too, after a reload, so a design also showed inside them. --- src/main/browsers/driven/DrivenBase.ts | 33 ++++++- src/main/browsers/driven/DrivenBrowsers.ts | 10 ++ .../driven/chromium/ChromiumDesigns.ts | 55 +++++++++++ .../driven/chromium/DrivenChromium.ts | 24 +++-- .../browsers/driven/chromium/captureTabAt.ts | 5 +- src/main/browsers/driven/chromium/fitTab.ts | 9 ++ src/main/browsers/driven/constants.ts | 3 + .../browsers/driven/firefox/DrivenFirefox.ts | 23 +++-- .../browsers/driven/firefox/FirefoxDesigns.ts | 92 +++++++++++++++++++ src/main/browsers/driven/firefox/constants.ts | 3 + .../browsers/driven/firefox/evaluateIn.ts | 10 +- src/main/browsers/driven/types.ts | 15 +++ src/main/engine/bidi/constants.ts | 2 +- src/main/launch/createWindow.ts | 3 +- src/main/overlay/DesignOverlay.ts | 10 +- src/main/overlay/PageOverlay.ts | 4 +- src/main/overlay/index.ts | 6 ++ src/main/overlay/overlayCall.ts | 4 +- src/main/overlay/overlaySource.ts | 5 +- src/main/overlay/types.ts | 14 ++- src/main/shots/PageShots/types.ts | 3 + test/integration/driven.chromium.test.ts | 25 +++++ test/integration/driven.firefox.test.ts | 25 +++++ test/integration/overlay.chromium.test.ts | 17 +++- 24 files changed, 357 insertions(+), 43 deletions(-) create mode 100644 src/main/browsers/driven/chromium/ChromiumDesigns.ts create mode 100644 src/main/browsers/driven/chromium/fitTab.ts create mode 100644 src/main/browsers/driven/firefox/FirefoxDesigns.ts diff --git a/src/main/browsers/driven/DrivenBase.ts b/src/main/browsers/driven/DrivenBase.ts index d4b5599..30b4dda 100644 --- a/src/main/browsers/driven/DrivenBase.ts +++ b/src/main/browsers/driven/DrivenBase.ts @@ -1,10 +1,14 @@ import type { CaptureArea, DrivenBrowser } from '../../../shared/types'; -import type { CapturedImage } from '../../shots/capture'; +import type { PageDesign } from '../../overlay'; +import type { CapturedImage, Viewport } from '../../shots/capture'; import type { FoundBrowser } from '../types'; import { DrivenTabs } from './DrivenTabs'; -import type { DriverDeps, KeptTab, TabCapture, TabRead } from './types'; +import type { DriverDeps, KeptTab, TabCapture, TabDesigns, TabRead } from './types'; -/** What every driven browser does the same way, whatever protocol it speaks: its tabs, listed, read again and captured. */ +/** + * What every driven browser does the same way, whatever protocol it speaks: its tabs, listed, read again and captured + * (with the design off them while they are). + */ export abstract class DrivenBase { protected readonly tabs = new DrivenTabs(); protected readonly disposers: Array<() => void> = []; @@ -31,12 +35,33 @@ export abstract class DrivenBase { async capture(tabId: string, area: Exclude): Promise { const tab = this.tabs.get(tabId); await this.activate(tabId); - return { image: await this.take(tab, area), url: tab.info.url }; + return { image: await this.designs.hidden(tab, () => this.take(tab, area)), url: tab.info.url }; } + /** Captures the whole page at `url`, in the tab showing it (or one opened there, loaded), laid out in `viewport`. */ + async captureAt(url: string, viewport: Viewport): Promise { + const shown = this.tabs.showing(url); + const tab = shown ?? (await this.openLoaded(url)); + if (shown) await this.activate(tab.info.id); + return { image: await this.designs.hidden(tab, () => this.takeAt(tab, viewport)), url: tab.info.url }; + } + + setDesign(design: PageDesign | null): Promise { + return this.designs.set(design, this.tabs.all()); + } + + /** How the design is laid over this browser's tabs. */ + protected abstract readonly designs: TabDesigns; + /** Brings a tab to the front, in its window. */ abstract activate(tabId: string): Promise; + /** Opens an address as `open` does, resolving once it has loaded (or at least is on its way). */ + protected abstract openLoaded(url: string): Promise; + + /** Captures a tab's whole page, in front, laid out in `viewport`, once it has loaded and been quiet a moment. */ + protected abstract takeAt(tab: T, viewport: Viewport): Promise; + /** What the browser says of each tab now. */ protected abstract read(ids: string[]): Promise; diff --git a/src/main/browsers/driven/DrivenBrowsers.ts b/src/main/browsers/driven/DrivenBrowsers.ts index bd9ad77..024ccfe 100644 --- a/src/main/browsers/driven/DrivenBrowsers.ts +++ b/src/main/browsers/driven/DrivenBrowsers.ts @@ -1,5 +1,6 @@ import type { AppEvent, CaptureArea, DrivenBrowser, DrivenEngine, ShotBrowser } from '../../../shared/types'; import { HTTP_URL } from '../../constants'; +import type { PageDesign } from '../../overlay'; import type { BrowserCapture, CapturedImage, Viewport } from '../../shots/capture'; import { PAGE_AREAS } from '../../shots/constants'; import type { FoundBrowser } from '../types'; @@ -24,6 +25,8 @@ export class DrivenBrowsers { private readonly driven = new Map(); private readonly starting = new Map>(); private reloadTimer: ReturnType | undefined; + /** The design over the app's page, if any: driven tabs have it too. */ + private design: PageDesign | null = null; private readonly reactions: AppEventReactions = { 'overrides-changed': () => this.served(), 'rules-changed': () => this.served(), @@ -81,6 +84,12 @@ export class DrivenBrowsers { this.changed(); } + /** Lays the design over the app's page over every driven tab too (null: takes it off), and over tabs opened later. */ + async setDesign(design: PageDesign | null): Promise { + this.design = design; + await Promise.all(this.all().map((d) => d.setDesign(design).catch(() => undefined))); + } + /** Told every app event: overrides, rules and settings that change are served in the driven tabs too. */ onAppEvent(event: AppEvent): void { this.reactions[event.type]?.(); @@ -118,6 +127,7 @@ export class DrivenBrowsers { this.changed(); }; const driven = await connect(browser, { sources: this.deps.sources, userData: this.deps.userData, changed: () => this.changed(), closed }); + await driven.setDesign(this.design).catch(() => undefined); this.driven.set(browser.id, driven); this.changed(); return driven; diff --git a/src/main/browsers/driven/chromium/ChromiumDesigns.ts b/src/main/browsers/driven/chromium/ChromiumDesigns.ts new file mode 100644 index 0000000..89f6f27 --- /dev/null +++ b/src/main/browsers/driven/chromium/ChromiumDesigns.ts @@ -0,0 +1,55 @@ +import { PageOverlay, type PageDesign } from '../../../overlay'; +import type { TabDesigns } from '../types'; +import { fitTab } from './fitTab'; +import type { DrivenTabState } from './types'; + +/** + * The app's design over a Chromium browser's tabs: each tab its own {@link PageOverlay} on its session (the app page's + * code), the page laid out at the design's width while that is on. + */ +export class ChromiumDesigns implements TabDesigns { + private design: PageDesign | null = null; + /** Each tab's overlay, and which design's image it has (a restyle keeps it). */ + private readonly laid = new Map(); + + async set(design: PageDesign | null, tabs: readonly DrivenTabState[]): Promise { + this.design = design; + await Promise.all(tabs.map((tab) => this.lay(tab, design).catch(() => undefined))); + } + + async found(tab: DrivenTabState): Promise { + if (!this.design) return; + await tab.ready; + await this.lay(tab, this.design).catch(() => undefined); + } + + async hidden(tab: DrivenTabState, task: () => Promise): Promise { + const design = this.design; + if (!design) return task(); + await this.lay(tab, { ...design, settings: { ...design.settings, hidden: true, fitWidth: false } }).catch(() => undefined); + try { + return await task(); + } finally { + if (this.design) await this.lay(tab, this.design).catch(() => undefined); + } + } + + gone(tab: DrivenTabState): void { + this.laid.delete(tab.info.id); + } + + private async lay(tab: DrivenTabState, design: PageDesign | null): Promise { + const laid = this.laid.get(tab.info.id) ?? { overlay: new PageOverlay(tab.transport), key: '' }; + this.laid.set(tab.info.id, laid); + if (!design) { + laid.key = ''; + await laid.overlay.remove(); + return fitTab(tab.transport, null); + } + const { key, base64, width, height, settings } = design; + if (laid.key === key) await laid.overlay.restyle(settings, width, height); + else await laid.overlay.show(base64, settings, width, height); + laid.key = key; + await fitTab(tab.transport, settings.fitWidth ? width : null); + } +} diff --git a/src/main/browsers/driven/chromium/DrivenChromium.ts b/src/main/browsers/driven/chromium/DrivenChromium.ts index 77cd3d7..06fe047 100644 --- a/src/main/browsers/driven/chromium/DrivenChromium.ts +++ b/src/main/browsers/driven/chromium/DrivenChromium.ts @@ -8,7 +8,8 @@ import { attachTab } from './attachTab'; import { captureTabAt } from './captureTabAt'; import { NEW_TAB_TIMEOUT_MS, START_URL } from '../constants'; import { DrivenBase } from '../DrivenBase'; -import type { Driver, DriverDeps, TabCapture, TabRead } from '../types'; +import type { Driver, DriverDeps, TabRead } from '../types'; +import { ChromiumDesigns } from './ChromiumDesigns'; import { PAGE_ATTACH, PAGE_TARGET, STOP_ATTACH } from './constants'; import { letTargetGo } from './letTargetGo'; import { readTabInfos } from './readTabInfos'; @@ -22,6 +23,8 @@ import type { AttachedPage, DrivenTabState, PageTargetInfo } from './types'; * the app's page. Tabs are listed with their address and title as they change. */ export class DrivenChromium extends DrivenBase implements Driver { + protected readonly designs = new ChromiumDesigns(); + constructor( browser: FoundBrowser, version: string | null, @@ -60,14 +63,6 @@ export class DrivenChromium extends DrivenBase implements Driver return tab; } - /** Captures the whole page at `url`, in the tab showing it (or one opened there), laid out in `viewport`. */ - async captureAt(url: string, viewport: Viewport): Promise { - const shown = this.tabs.showing(url); - const tab = shown ?? (await this.open(url)); - if (shown) await this.activate(tab.info.id); - return captureTabAt(tab, viewport); - } - async activate(tabId: string): Promise { this.tabs.get(tabId); await this.connection.send(CDP.Target.activateTarget, { targetId: tabId }); @@ -103,12 +98,14 @@ export class DrivenChromium extends DrivenBase implements Driver tab.transport.on(CDP.Page.loadEventFired, () => void this.readTabs([tab.info.id])); this.tabs.add(tab); this.deps.changed(); + void this.designs.found(tab); } private detached(sessionId: string): void { const tab = this.tabs.all().find((t) => t.sessionId === sessionId); if (!tab) return; this.tabs.remove(tab.info.id); + this.designs.gone(tab); releaseTab(tab); this.deps.changed(); } @@ -121,6 +118,15 @@ export class DrivenChromium extends DrivenBase implements Driver return captureOverCdp(tab.transport, { area }); } + protected openLoaded(url: string): Promise { + // Page.navigate answers once the page is committed: the capture waits for its load. + return this.open(url); + } + + protected takeAt(tab: DrivenTabState, viewport: Viewport): Promise { + return captureTabAt(tab, viewport); + } + private async newTab(): Promise { const { targetId } = await this.connection.send<{ targetId: string }>(CDP.Target.createTarget, { url: START_URL }); return withTimeout(this.tabs.arrival(targetId), NEW_TAB_TIMEOUT_MS, 'Opening a tab'); diff --git a/src/main/browsers/driven/chromium/captureTabAt.ts b/src/main/browsers/driven/chromium/captureTabAt.ts index 4f9ebc1..e79c5f1 100644 --- a/src/main/browsers/driven/chromium/captureTabAt.ts +++ b/src/main/browsers/driven/chromium/captureTabAt.ts @@ -6,9 +6,8 @@ import { LOAD_TIMEOUT_MS } from '../constants'; import type { DrivenTabState } from './types'; /** Captures the whole page a tab shows, laid out in `viewport`, once it has loaded and been quiet a moment. */ -export async function captureTabAt(tab: DrivenTabState, viewport: Viewport): Promise<{ image: CapturedImage; url: string }> { +export async function captureTabAt(tab: DrivenTabState, viewport: Viewport): Promise { // A page still loading after that is captured as it is. await withTimeout(tab.transport.send(CDP.Runtime.evaluate, { expression: LOADED_EXPRESSION, awaitPromise: true }), LOAD_TIMEOUT_MS, 'Loading the page').catch(() => undefined); - const image = await atViewport(tab.transport, viewport, () => captureOverCdp(tab.transport, { area: 'page' })); - return { image, url: tab.info.url }; + return atViewport(tab.transport, viewport, () => captureOverCdp(tab.transport, { area: 'page' })); } diff --git a/src/main/browsers/driven/chromium/fitTab.ts b/src/main/browsers/driven/chromium/fitTab.ts new file mode 100644 index 0000000..b0f0ced --- /dev/null +++ b/src/main/browsers/driven/chromium/fitTab.ts @@ -0,0 +1,9 @@ +import type { CdpTransport } from '../../../engine/cdp'; +import { CDP } from '../../../engine/constants'; +import { FIT_METRICS } from '../constants'; + +/** Lays a Chromium tab's page out `width` CSS pixels wide (a design's), its window's height kept; null: its own width. */ +export async function fitTab(transport: CdpTransport, width: number | null): Promise { + if (width === null) await transport.send(CDP.Emulation.clearDeviceMetricsOverride); + else await transport.send(CDP.Emulation.setDeviceMetricsOverride, { width, ...FIT_METRICS }); +} diff --git a/src/main/browsers/driven/constants.ts b/src/main/browsers/driven/constants.ts index c5524ce..42cceb7 100644 --- a/src/main/browsers/driven/constants.ts +++ b/src/main/browsers/driven/constants.ts @@ -27,3 +27,6 @@ export const NEW_TAB_TIMEOUT_MS = 10_000; /** Waiting this long after overrides or rules change before reloading driven tabs (a burst reloads them once). */ export const RELOAD_DEBOUNCE_MS = 300; + +/** A design's width laid out in another browser's tab: its window keeps its height (0: not overridden) and density. */ +export const FIT_METRICS = { height: 0, deviceScaleFactor: 0, mobile: false } as const; diff --git a/src/main/browsers/driven/firefox/DrivenFirefox.ts b/src/main/browsers/driven/firefox/DrivenFirefox.ts index 533ee93..b891013 100644 --- a/src/main/browsers/driven/firefox/DrivenFirefox.ts +++ b/src/main/browsers/driven/firefox/DrivenFirefox.ts @@ -5,9 +5,10 @@ import type { CapturedImage, Viewport } from '../../../shots/capture'; import type { FoundBrowser } from '../../types'; import { LOAD_TIMEOUT_MS, START_URL } from '../constants'; import { DrivenBase } from '../DrivenBase'; -import type { Driver, DriverDeps, KeptTab, TabCapture, TabRead } from '../types'; +import type { Driver, DriverDeps, KeptTab, TabRead } from '../types'; import { captureContext } from './captureContext'; import { captureContextAt } from './captureContextAt'; +import { FirefoxDesigns } from './FirefoxDesigns'; import { NAVIGATE_WAIT, TAB_TYPE } from './constants'; import { readFirefoxTabs } from './readFirefoxTabs'; import type { ContextInfo } from './types'; @@ -19,6 +20,7 @@ import type { ContextInfo } from './types'; */ export class DrivenFirefox extends DrivenBase implements Driver { private readonly interception: BidiInterception; + protected readonly designs: FirefoxDesigns; constructor( browser: FoundBrowser, @@ -27,6 +29,7 @@ export class DrivenFirefox extends DrivenBase implements Driver { deps: DriverDeps, ) { super(browser, version, deps); + this.designs = new FirefoxDesigns(connection); const { store, rules, settings } = deps.sources; this.interception = new BidiInterception(connection, { getOverrides: () => store.list(), getRules: () => rules.list(), getSettings: () => settings.get() }); } @@ -62,12 +65,6 @@ export class DrivenFirefox extends DrivenBase implements Driver { await this.connection.send(BIDI.browsingContext.activate, { context: tabId }); } - async captureAt(url: string, viewport: Viewport): Promise { - const shown = this.tabs.showing(url); - const tab = shown ?? (await withTimeout(this.load(url, NAVIGATE_WAIT.loaded), LOAD_TIMEOUT_MS, 'Loading the page')); - if (shown) await this.activate(tab.info.id); - return { image: await captureContextAt(this.connection, tab.info.id, viewport), url: tab.info.url }; - } refresh(): Promise { return this.interception.refresh(); @@ -97,6 +94,14 @@ export class DrivenFirefox extends DrivenBase implements Driver { return captureContext(this.connection, tab.info.id, area); } + protected openLoaded(url: string): Promise { + return withTimeout(this.load(url, NAVIGATE_WAIT.loaded), LOAD_TIMEOUT_MS, 'Loading the page'); + } + + protected takeAt(tab: KeptTab, viewport: Viewport): Promise { + return captureContextAt(this.connection, tab.info.id, viewport); + } + /** Loads an address in the blank tab the browser started on, or else in a new tab, and brings it to the front. */ private async load(url: string, wait: string): Promise { const tab = this.tabs.blank() ?? (await this.newTab()); @@ -115,8 +120,10 @@ export class DrivenFirefox extends DrivenBase implements Driver { private found(id: string, url: string): void { if (this.tabs.has(id)) return; - this.tabs.add({ info: { id, title: '', url } }); + const tab = { info: { id, title: '', url } }; + this.tabs.add(tab); this.deps.changed(); + void this.designs.found(tab); } /** The browser was quit: its tabs are gone with it. */ diff --git a/src/main/browsers/driven/firefox/FirefoxDesigns.ts b/src/main/browsers/driven/firefox/FirefoxDesigns.ts new file mode 100644 index 0000000..932ef26 --- /dev/null +++ b/src/main/browsers/driven/firefox/FirefoxDesigns.ts @@ -0,0 +1,92 @@ +import { BIDI, type BidiConnection } from '../../../engine/bidi'; +import { OVERLAY_JS, OVERLAY_WORLD, overlayCall, overlayStyle, type PageDesign } from '../../../overlay'; +import type { KeptTab, TabDesigns } from '../types'; +import { HEIGHT_EXPRESSION } from './constants'; +import { evaluateIn } from './evaluateIn'; + +/** + * The app's design over Firefox's tabs: the app page's overlay code, style and image as preload scripts in a sandbox + * of every new document (a page's scripts can't reach it), run in each tab's document loaded now; the page laid out at + * the design's width (BiDi's viewport) while that is on. + */ +export class FirefoxDesigns implements TabDesigns { + private design: PageDesign | null = null; + private scripts: { code?: string; style?: string; image?: string } = {}; + private key = ''; + + constructor(private readonly connection: BidiConnection) {} + + async set(design: PageDesign | null, tabs: readonly KeptTab[]): Promise { + this.design = design; + if (!design) { + this.key = ''; + await this.unload(Object.values(this.scripts)); + this.scripts = {}; + await Promise.all(tabs.map((tab) => this.run(tab, [overlayCall('remove')]).then(() => this.fit(tab, null)))); + return; + } + const style = this.style(design, false); + if (this.key === design.key) { + await this.unload([this.scripts.style]); + this.scripts.style = await this.preload(style); + await Promise.all(tabs.map((tab) => this.run(tab, [style]))); + } else { + await this.unload(Object.values(this.scripts)); + const image = overlayCall('setImage', design.base64); + this.scripts = { code: await this.preload(OVERLAY_JS), style: await this.preload(style), image: await this.preload(image) }; + this.key = design.key; + await Promise.all(tabs.map((tab) => this.run(tab, [OVERLAY_JS, style, image]))); + } + await Promise.all(tabs.map((tab) => this.fit(tab, design))); + } + + async found(tab: KeptTab): Promise { + // New documents get the preload scripts; only the width is the tab's own. + if (this.design) await this.fit(tab, this.design); + } + + async hidden(tab: KeptTab, task: () => Promise): Promise { + const design = this.design; + if (!design) return task(); + await this.run(tab, [this.style(design, true)]); + await this.fit(tab, null); + try { + return await task(); + } finally { + if (this.design) { + await this.run(tab, [this.style(this.design, false)]); + await this.fit(tab, this.design); + } + } + } + + gone(): void { + // Preload scripts are the browser's, not a tab's: nothing is kept per tab. + } + + private style({ settings, width, height }: PageDesign, hidden: boolean): string { + return overlayCall('setStyle', overlayStyle({ ...settings, hidden: hidden || settings.hidden }, width, height), settings.blend); + } + + /** Runs each expression in turn in a tab's document, in the overlay's sandbox. */ + private async run(tab: KeptTab, expressions: string[]): Promise { + for (const expression of expressions) await evaluateIn(this.connection, tab.info.id, expression, true, OVERLAY_WORLD); + } + + /** The tab's page at the design's width (its window's height kept) while that is on, else at its own. */ + private async fit(tab: KeptTab, design: PageDesign | null): Promise { + const context = tab.info.id; + const height = design?.settings.fitWidth ? Number(await evaluateIn(this.connection, context, HEIGHT_EXPRESSION)) : 0; + const viewport = design?.settings.fitWidth ? { width: design.width, height: height || design.height } : null; + await this.connection.send(BIDI.browsingContext.setViewport, { context, viewport }).catch(() => undefined); + } + + private async preload(expression: string): Promise { + const { script } = await this.connection.send<{ script: string }>(BIDI.script.addPreloadScript, { functionDeclaration: `() => { ${expression}; }`, sandbox: OVERLAY_WORLD }); + return script; + } + + private async unload(scripts: Array): Promise { + await Promise.all(scripts.filter((script) => script !== undefined).map((script) => this.connection.send(BIDI.script.removePreloadScript, { script }).catch(() => undefined))); + } +} diff --git a/src/main/browsers/driven/firefox/constants.ts b/src/main/browsers/driven/firefox/constants.ts index a7f8457..304661a 100644 --- a/src/main/browsers/driven/firefox/constants.ts +++ b/src/main/browsers/driven/firefox/constants.ts @@ -36,3 +36,6 @@ export const TAB_TYPE = 'tab'; /** How far `navigate` waits: not at all (opening a tab), or until the page has loaded (capturing it). */ export const NAVIGATE_WAIT = { none: 'none', loaded: 'complete' } as const; + +/** A tab's window height, asked of its page to keep it while its width is a design's. */ +export const HEIGHT_EXPRESSION = 'String(innerHeight)'; diff --git a/src/main/browsers/driven/firefox/evaluateIn.ts b/src/main/browsers/driven/firefox/evaluateIn.ts index 4555549..e836b18 100644 --- a/src/main/browsers/driven/firefox/evaluateIn.ts +++ b/src/main/browsers/driven/firefox/evaluateIn.ts @@ -6,9 +6,13 @@ interface Evaluated { result?: { type: string; value?: unknown }; } -/** Runs `expression` in a tab's page (awaited when it is a promise); its value when that is a string, else null. */ -export async function evaluateIn(connection: BidiConnection, context: string, expression: string, awaitPromise = false): Promise { - const evaluated = await connection.send(BIDI.script.evaluate, { expression, target: { context }, awaitPromise }).catch(() => null); +/** + * Runs `expression` in a tab's page (in a sandbox of it when named; awaited when it is a promise); its value when that + * is a string, else null. + */ +export async function evaluateIn(connection: BidiConnection, context: string, expression: string, awaitPromise = false, sandbox?: string): Promise { + const target = sandbox ? { context, sandbox } : { context }; + const evaluated = await connection.send(BIDI.script.evaluate, { expression, target, awaitPromise }).catch(() => null); const value = evaluated?.result?.value; return typeof value === 'string' ? value : null; } diff --git a/src/main/browsers/driven/types.ts b/src/main/browsers/driven/types.ts index 4ac58f9..e21b969 100644 --- a/src/main/browsers/driven/types.ts +++ b/src/main/browsers/driven/types.ts @@ -1,4 +1,5 @@ import type { AppEvent, CaptureArea, DrivenBrowser, DrivenTab } from '../../../shared/types'; +import type { PageDesign } from '../../overlay'; import type { CapturedImage, Viewport } from '../../shots/capture'; import type { OverrideStore } from '../../store/OverrideStore'; import type { RuleStore } from '../../store/RuleStore'; @@ -50,10 +51,24 @@ export interface Driver { applySettings(): Promise; /** Reloads its tabs showing a website. */ reload(): Promise; + /** Lays the design over every tab (null: takes it off), and over tabs opened later. */ + setDesign(design: PageDesign | null): Promise; /** Stops serving the workspace's changes; the browser stays open. */ stop(): void; } +/** How a driver lays the app's design over its tabs. */ +export interface TabDesigns { + /** Lays `design` over each of `tabs` (null: takes it off), and keeps it for tabs found later. */ + set(design: PageDesign | null, tabs: readonly T[]): Promise; + /** Lays the design over a tab found since (nothing while there is none). */ + found(tab: T): Promise; + /** Runs `task` (a capture) with the design off the tab and the page at its own width, then lays it back. */ + hidden(tab: T, task: () => Promise): Promise; + /** Forgets a tab that closed. */ + gone(tab: T): void; +} + export interface DriverDeps { sources: InterceptionSources; /** The app's data folder, where driven browsers keep their profiles. */ diff --git a/src/main/engine/bidi/constants.ts b/src/main/engine/bidi/constants.ts index 0b627e1..e94ca6f 100644 --- a/src/main/engine/bidi/constants.ts +++ b/src/main/engine/bidi/constants.ts @@ -29,7 +29,7 @@ export const BIDI = { beforeRequestSent: 'network.beforeRequestSent', responseStarted: 'network.responseStarted', }, - script: { evaluate: 'script.evaluate' }, + script: { addPreloadScript: 'script.addPreloadScript', evaluate: 'script.evaluate', removePreloadScript: 'script.removePreloadScript' }, } as const; /** The phases the interception pauses requests at: before they are sent (to block or answer them), and at their response's head. */ diff --git a/src/main/launch/createWindow.ts b/src/main/launch/createWindow.ts index 74b4c9b..697b719 100644 --- a/src/main/launch/createWindow.ts +++ b/src/main/launch/createWindow.ts @@ -59,7 +59,8 @@ export async function createWindow(updateFeed: string | undefined): Promise activeBreakpoints(session) }); launchState.running = { win, page }; // Before the engine attaches: it serves the active workspace's overrides and rules from the start. - const shots = new PageShots({ store: shotStore, page, send }); + // The design laid over the page goes over the tabs of browsers driven with the workspace's changes too. + const shots = new PageShots({ store: shotStore, page, send, onDesign: (design) => void driven.setDesign(design) }); registerShotProtocol(shotStore); const workspaces = new WorkspaceController(page, session, store, rules, actions, send, sourceMaps, shots); await workspaces.start(); diff --git a/src/main/overlay/DesignOverlay.ts b/src/main/overlay/DesignOverlay.ts index 7083566..c3bfa8d 100644 --- a/src/main/overlay/DesignOverlay.ts +++ b/src/main/overlay/DesignOverlay.ts @@ -16,6 +16,8 @@ const PATCH_SCHEMA = overlaySettingsSchema.partial(); */ export class DesignOverlay { private state: OverlayState | null = null; + /** The design's image, as base64: what other browsers' tabs are given too. */ + private image = ''; private readonly overlay: PageOverlay; /** The width the page is laid out at and the view's size then ('' at its own width): unchanged, it isn't laid out again. */ private fitted = ''; @@ -33,8 +35,10 @@ export class DesignOverlay { const shot = this.deps.store.get(shotId); const bytes = await this.deps.store.read(shotId); const state: OverlayState = { shotId: shot.id, name: shot.name, width: Math.round(shot.width / shot.scale), height: Math.round(shot.height / shot.scale), settings: this.state?.settings ?? DEFAULT_OVERLAY_SETTINGS }; - await this.overlay.show(bytes.toString('base64'), state.settings, state.width, state.height); + const image = bytes.toString('base64'); + await this.overlay.show(image, state.settings, state.width, state.height); this.state = state; + this.image = image; this.fit(); this.announce(); return state; @@ -93,6 +97,8 @@ export class DesignOverlay { } private announce(): void { - this.deps.send({ type: 'overlay-changed', overlay: this.state }); + const { state } = this; + this.deps.send({ type: 'overlay-changed', overlay: state }); + this.deps.onDesign?.(state && { key: state.shotId, base64: this.image, width: state.width, height: state.height, settings: state.settings }); } } diff --git a/src/main/overlay/PageOverlay.ts b/src/main/overlay/PageOverlay.ts index ea3cbab..e3d801d 100644 --- a/src/main/overlay/PageOverlay.ts +++ b/src/main/overlay/PageOverlay.ts @@ -1,7 +1,7 @@ import type { OverlaySettings } from '../../shared/types'; import { CDP } from '../engine/constants'; import type { CdpTransport } from '../engine/cdp'; -import { OVERLAY_GLOBAL, OVERLAY_WORLD } from './constants'; +import { OVERLAY_WORLD } from './constants'; import { overlayCall } from './overlayCall'; import { OVERLAY_JS } from './overlaySource'; import { overlayStyle } from './overlayStyle'; @@ -40,7 +40,7 @@ export class PageOverlay { this.scripts = {}; if (!ids.length) return; await Promise.all(ids.map((identifier) => this.transport.send(CDP.Page.removeScriptToEvaluateOnNewDocument, { identifier }).catch(() => undefined))); - await this.run(await this.world(), `globalThis.${OVERLAY_GLOBAL} && ${overlayCall('remove')}`).catch(() => undefined); + await this.run(await this.world(), overlayCall('remove')).catch(() => undefined); } private async add(source: string): Promise { diff --git a/src/main/overlay/index.ts b/src/main/overlay/index.ts index 4a81cc8..8308a68 100644 --- a/src/main/overlay/index.ts +++ b/src/main/overlay/index.ts @@ -1 +1,7 @@ export { DesignOverlay } from './DesignOverlay'; +export { OVERLAY_WORLD } from './constants'; +export { overlayCall } from './overlayCall'; +export { OVERLAY_JS } from './overlaySource'; +export { overlayStyle } from './overlayStyle'; +export { PageOverlay } from './PageOverlay'; +export type { PageDesign } from './types'; diff --git a/src/main/overlay/overlayCall.ts b/src/main/overlay/overlayCall.ts index 59fa31c..f3911af 100644 --- a/src/main/overlay/overlayCall.ts +++ b/src/main/overlay/overlayCall.ts @@ -1,6 +1,6 @@ import { OVERLAY_GLOBAL } from './constants'; -/** A call of the overlay's controller in its world, its arguments written as JSON. */ +/** A call of the overlay's controller in its world (none in a frame's document), its arguments written as JSON. */ export function overlayCall(method: string, ...args: string[]): string { - return `globalThis.${OVERLAY_GLOBAL}.${method}(${args.map((a) => JSON.stringify(a)).join(', ')})`; + return `globalThis.${OVERLAY_GLOBAL}?.${method}(${args.map((a) => JSON.stringify(a)).join(', ')})`; } diff --git a/src/main/overlay/overlaySource.ts b/src/main/overlay/overlaySource.ts index dddea23..2227723 100644 --- a/src/main/overlay/overlaySource.ts +++ b/src/main/overlay/overlaySource.ts @@ -6,10 +6,11 @@ import { BACKDROP, DIFFERENCE, OVERLAY_ELEMENT_ID, OVERLAY_GLOBAL, TRANSPARENT } * the page removes it; `setStyle` places and styles it; `remove` takes it off. A canvas, not an ``: a page's * CSP can refuse a data: image, but not pixels drawn from bytes. Blended as a difference, it needs a backdrop: a page * that paints no background of its own shows the browser's white, which nothing blends with, so it is given that - * white (no change to see) while it is, and its own back after. + * white (no change to see) while it is, and its own back after. Only a top document gets one: the scripts run in + * every frame's document. */ export const OVERLAY_JS = `(() => { - if (globalThis.${OVERLAY_GLOBAL}) return; + if (globalThis.${OVERLAY_GLOBAL} || window.top !== window) return; let canvas = null; let style = ''; let difference = false; diff --git a/src/main/overlay/types.ts b/src/main/overlay/types.ts index 7f08187..f0ae5b2 100644 --- a/src/main/overlay/types.ts +++ b/src/main/overlay/types.ts @@ -1,4 +1,4 @@ -import type { AppEvent } from '../../shared/types'; +import type { AppEvent, OverlaySettings } from '../../shared/types'; import type { PageController } from '../PageController'; import type { ShotStore } from '../store/ShotStore'; @@ -6,4 +6,16 @@ export interface DesignOverlayDeps { page: PageController; store: ShotStore; send(event: AppEvent): void; + /** Told the design as it is laid, restyled or taken off (null), for other browsers' tabs to have it too. */ + onDesign?(design: PageDesign | null): void; +} + +/** A design as laid over a page: its image (base64), its size in CSS pixels, and how it is laid. */ +export interface PageDesign { + /** Which image it is (its shot's id): the same one is only restyled. */ + key: string; + base64: string; + width: number; + height: number; + settings: OverlaySettings; } diff --git a/src/main/shots/PageShots/types.ts b/src/main/shots/PageShots/types.ts index a9b9c1e..1b8b593 100644 --- a/src/main/shots/PageShots/types.ts +++ b/src/main/shots/PageShots/types.ts @@ -1,5 +1,6 @@ import type { AppEvent } from '../../../shared/types'; import type { PageController } from '../../PageController'; +import type { PageDesign } from '../../overlay'; import type { ShotStore } from '../../store/ShotStore'; export interface PageShotsDeps { @@ -7,4 +8,6 @@ export interface PageShotsDeps { page: PageController; /** Pushes an event to the app's windows. */ send(event: AppEvent): void; + /** Told the design laid over the page as it changes (null: taken off). */ + onDesign?(design: PageDesign | null): void; } diff --git a/test/integration/driven.chromium.test.ts b/test/integration/driven.chromium.test.ts index 06933e8..d3e71ff 100644 --- a/test/integration/driven.chromium.test.ts +++ b/test/integration/driven.chromium.test.ts @@ -21,6 +21,7 @@ import { defaultMatcherFor } from '../../src/shared/matcher'; import { DEFAULT_SETTINGS, type AppEvent, type DrivenTab, type Override, type Settings } from '../../src/shared/types'; import { chromiumAvailable } from '../helpers/chromium'; import { decodePng } from '../helpers/decodePng'; +import { encodePng } from '../helpers/encodePng'; async function waitFor(fn: () => T | undefined | false | Promise, timeout = 20_000): Promise { const deadline = Date.now() + timeout; @@ -61,6 +62,7 @@ describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes const path = new URL(req.url ?? '/', 'http://x').pathname; const files: Record = { '/page.html': ['text/html', 'upstream'], + '/design.html': ['text/html', `-`], '/app.js': ['text/javascript', "document.title = 'upstream';"], }; const file = files[path]; @@ -139,6 +141,29 @@ describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes expect(tabs()).toHaveLength(3); }); + it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { + const title = async () => (await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? ''; + await driven.open(browser.id, `${origin}/design.html`); + const settings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true } as const; + const design = { key: 'd1', base64: encodePng(40, 30, () => [255, 0, 0]).toString('base64'), width: 300, height: 200, settings }; + await driven.setDesign(design); + await waitFor(async () => (await title()) === 'design 0.5 300'); + await driven.setDesign({ ...design, settings: { ...settings, opacity: 1 } }); + await waitFor(async () => (await title()) === 'design 1 300'); + // Captured without it, at the window's own width. + const tab = (await driven.read())[0].tabs.find((t) => t.url === `${origin}/design.html`)!; + const { image } = await driven.capture(browser.id, tab.id, 'viewport'); + expect(decodePng(image.bytes).at(5, 5)).toEqual([0, 0, 255, 255]); + expect(image.viewport.width).not.toBe(300); + await waitFor(async () => (await title()) === 'design 1 300'); + // A new document gets it too. + driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); + await waitFor(async () => (await title()) === '-'); + await waitFor(async () => (await title()) === 'design 1 300'); + await driven.setDesign(null); + await waitFor(async () => /^no design \d+$/.test(await title()) && !(await title()).endsWith(' 300')); + }); + it('refuses a browser it can\'t drive (Safari), and an address not on the web', async () => { const other = new DrivenBrowsers({ registry: { get: async () => ({ ...browser, name: 'Safari', engine: 'webkit' }) }, sources, userData, send: () => undefined }); await expect(other.open(browser.id, `${origin}/page.html`)).rejects.toThrow("Safari can't be served your changes"); diff --git a/test/integration/driven.firefox.test.ts b/test/integration/driven.firefox.test.ts index dacf531..c24f275 100644 --- a/test/integration/driven.firefox.test.ts +++ b/test/integration/driven.firefox.test.ts @@ -20,6 +20,7 @@ import { BidiConnection } from '../../src/main/engine/bidi'; import { defaultMatcherFor } from '../../src/shared/matcher'; import { DEFAULT_SETTINGS, type AppEvent, type DrivenTab, type Override, type Rule, type Settings } from '../../src/shared/types'; import { decodePng } from '../helpers/decodePng'; +import { encodePng } from '../helpers/encodePng'; import { killMatching } from '../helpers/killMatching'; const firefox = (() => { @@ -64,6 +65,7 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { const script = `Promise.all([fetch('/api').then((r) => r.headers.get('x-rule')), fetch('/blocked.js').then(() => 'loaded', () => 'blocked'), fetch('${other}/data', { method: 'PUT' }).then((r) => r.text(), () => 'refused')]).then((all) => { document.title = [window.appValue, ...all].join(' '); });`; const files: Record = { '/page.html': ['text/html', `upstream`], + '/design.html': ['text/html', `-`], '/app.js': ['text/javascript', "window.appValue = 'upstream';"], '/api': ['application/json', '{}'], '/blocked.js': ['text/javascript', ''], @@ -130,6 +132,29 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { await waitFor(async () => (await tabs()).some((t) => t.url.endsWith('?again') && t.title.startsWith('changed'))); }); + it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { + const title = async () => (await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? ''; + await driven.open(browser.id, `${origin}/design.html`); + const settings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true } as const; + const design = { key: 'd1', base64: encodePng(40, 30, () => [255, 0, 0]).toString('base64'), width: 300, height: 200, settings }; + await driven.setDesign(design); + await waitFor(async () => (await title()) === 'design 0.5 300'); + await driven.setDesign({ ...design, settings: { ...settings, opacity: 1 } }); + await waitFor(async () => (await title()) === 'design 1 300'); + // Captured without it, at the window's own width. + const tab = (await driven.read())[0].tabs.find((t) => t.url === `${origin}/design.html`)!; + const { image } = await driven.capture(browser.id, tab.id, 'viewport'); + expect(decodePng(image.bytes).at(5, 5)).toEqual([0, 0, 255, 255]); + expect(image.viewport.width).not.toBe(300); + await waitFor(async () => (await title()) === 'design 1 300'); + // A new document gets it too. + driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); + await waitFor(async () => (await title()) === '-'); + await waitFor(async () => (await title()) === 'design 1 300'); + await driven.setDesign(null); + await waitFor(async () => /^no design \d+$/.test(await title()) && !(await title()).endsWith(' 300')); + }); + it('forgets it once it is quit', async () => { killMatching(profile()); await waitFor(() => driven.list().length === 0); diff --git a/test/integration/overlay.chromium.test.ts b/test/integration/overlay.chromium.test.ts index c9e4202..cd267d4 100644 --- a/test/integration/overlay.chromium.test.ts +++ b/test/integration/overlay.chromium.test.ts @@ -1,7 +1,7 @@ /** * A design laid over a page through CDP, against real Chromium, on a page whose CSP forbids images from anywhere: - * drawn all the same (a canvas in an isolated world), restyled, kept after a reload, out of the pointer's way, and - * taken off. + * drawn all the same (a canvas in an isolated world), restyled, kept after a reload (over the top document only, + * not in its same-site frame), out of the pointer's way, and taken off. */ import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; @@ -26,9 +26,11 @@ describe.skipIf(!chromiumAvailable)('design overlay in Chromium', () => { const pixel = async (x: number, y: number) => decodePng((await captureOverCdp(transport, { area: 'viewport' })).bytes).at(x, y); beforeAll(async () => { - server = createServer((_req, res) => { - res.writeHead(200, { 'content-type': 'text/html', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" }); - res.end(''); + server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'" }); + // The page, with a same-site frame out of the way (bottom right), which must not get a design of its own. + if (req.url === '/frame.html') return void res.end('f'); + res.end(''); }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); chrome = await launchChromium(); @@ -76,6 +78,11 @@ describe.skipIf(!chromiumAvailable)('design overlay in Chromium', () => { await page.reload(); await expect.poll(() => pixel(55, 25)).toEqual([255, 0, 0, 255]); expect(await pixel(15, 25)).toEqual([0, 0, 255, 255]); + // The frame's new document ran the same scripts: it stays as it is. + // (A reload waits for the frame's load too.) + const frame = page.frames().find((f) => f.url().endsWith('/frame.html'))!; + expect(await frame.evaluate((id) => !!document.getElementById(id), OVERLAY_ELEMENT_ID)).toBe(false); + expect(await pixel(310, 210)).toEqual([0, 255, 0, 255]); await page.evaluate((id) => document.getElementById(id)?.remove(), OVERLAY_ELEMENT_ID); await expect.poll(() => page.evaluate((id) => !!document.getElementById(id), OVERLAY_ELEMENT_ID)).toBe(true); }); From e6da57a27073064ba9053fcc8463080373329a68 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Tue, 29 Sep 2026 09:37:04 +0000 Subject: [PATCH 12/19] Use your everyday Chrome with the workspace's changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With remote debugging turned on for it (chrome://inspect/#remote- debugging, from Chrome 144), a Chromium browser writes its debugging address in its everyday profile. The app finds that address in each Chromium browser's profile folder, on each system and in Snap and Flatpak ones. While the browser runs, its row in the browser menu (and the palette) offers "Use your own … with your changes". The app checks the port only by opening and closing a connection, so Chrome doesn't ask anything for it. Chrome asks you to allow the connection. After that, the app serves the workspace's overrides and rules only in the tabs it opens there; your own tabs are neither listed nor attached. Letting go leaves the browser as it was. The driven browsers now sit in a pool by key, so a browser can be driven with the app's profile and with your own at once. Each lists the installed browser it is, and which profile it uses. --- .../BrowserRegistry/BrowserRegistry.ts | 11 +- src/main/browsers/BrowserRegistry/infoOf.ts | 4 +- src/main/browsers/driven/DrivenBase.ts | 2 +- src/main/browsers/driven/DrivenBrowsers.ts | 99 ++++++--------- src/main/browsers/driven/DrivenTabs.ts | 6 +- src/main/browsers/driven/DriverPool.ts | 48 +++++++ src/main/browsers/driven/captureEvery.ts | 15 +++ .../driven/chromium/DrivenChromium.ts | 14 ++- .../driven/chromium/DrivenEverydayChrome.ts | 23 ++++ .../driven/everyday/connectEverydayChrome.ts | 27 ++++ .../browsers/driven/everyday/constants.ts | 33 +++++ .../driven/everyday/everydayProfileDir.ts | 23 ++++ .../browsers/driven/everyday/isListening.ts | 19 +++ .../driven/everyday/reachableEveryday.ts | 15 +++ src/main/browsers/driven/shotBrowser.ts | 4 +- src/main/browsers/driven/types.ts | 6 + src/main/browsers/types.ts | 2 + src/main/ipc/registerBrowserIpc.ts | 4 +- src/preload/browsersApi.ts | 2 +- .../open-in-browser/model/openWithChanges.ts | 12 +- .../open-in-browser/ui/BrowserMenu.tsx | 6 +- .../browser/open-in-browser/ui/BrowserRow.tsx | 19 ++- .../open-in-browser/ui/DrivenSection.tsx | 2 +- .../command-palette/ui/browserItems.ts | 14 ++- src/shared/types/browsers.ts | 8 +- src/shared/types/browsersApi.ts | 7 +- test/e2e/driven.e2e.test.ts | 28 ++++- .../everydayChrome.chromium.test.ts | 118 ++++++++++++++++++ test/unit/browsers.test.ts | 36 +++++- 29 files changed, 511 insertions(+), 96 deletions(-) create mode 100644 src/main/browsers/driven/DriverPool.ts create mode 100644 src/main/browsers/driven/captureEvery.ts create mode 100644 src/main/browsers/driven/chromium/DrivenEverydayChrome.ts create mode 100644 src/main/browsers/driven/everyday/connectEverydayChrome.ts create mode 100644 src/main/browsers/driven/everyday/constants.ts create mode 100644 src/main/browsers/driven/everyday/everydayProfileDir.ts create mode 100644 src/main/browsers/driven/everyday/isListening.ts create mode 100644 src/main/browsers/driven/everyday/reachableEveryday.ts create mode 100644 test/integration/everydayChrome.chromium.test.ts diff --git a/src/main/browsers/BrowserRegistry/BrowserRegistry.ts b/src/main/browsers/BrowserRegistry/BrowserRegistry.ts index aa25e0c..31bff9d 100644 --- a/src/main/browsers/BrowserRegistry/BrowserRegistry.ts +++ b/src/main/browsers/BrowserRegistry/BrowserRegistry.ts @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto'; +import { homedir } from 'node:os'; import { basename, extname } from 'node:path'; import type { BrowserInfo } from '../../../shared/types'; import { HTTP_URL } from '../../constants'; @@ -7,6 +8,7 @@ import { BROWSER_ID_PREFIX, SCAN_REUSE_MS } from '../constants'; import { engineOf } from '../engineOf'; import { findBrowsers } from '../findBrowsers'; import { readVersion } from '../readVersion'; +import { reachableEveryday } from '../driven/everyday/reachableEveryday'; import { startBrowser } from '../startBrowser'; import type { BrowserRegistryDeps, FoundBrowser } from '../types'; import { addedBrowser } from './addedBrowser'; @@ -27,11 +29,16 @@ export class BrowserRegistry { private readingVersions = false; private readonly icons = new Map(); private readonly versions = new Map(); + /** The browsers whose everyday profile ran with remote debugging on when they were last listed. */ + private debuggable = new Set(); constructor(private readonly deps: BrowserRegistryDeps) {} async list(): Promise { await this.scan(); + const home = this.deps.home ?? homedir(); + const reachable = await Promise.all(this.all().map(async (b) => ((await reachableEveryday(b, home)) ? [b.id] : []))); + this.debuggable = new Set(reachable.flat()); return this.infos(); } @@ -59,7 +66,7 @@ export class BrowserRegistry { this.icons.set(browser.id, await browserIcon(browser)); this.changed(); void this.readVersions(); - return infoOf(browser, this.icons.get(browser.id), null, new Set()); + return infoOf(browser, this.icons.get(browser.id), null, new Set(), new Set()); } async remove(id: string): Promise { @@ -79,7 +86,7 @@ export class BrowserRegistry { private infos(): BrowserInfo[] { const hidden = new Set(this.deps.prefs.get().hidden); - return this.all().map((b) => infoOf(b, this.icons.get(b.id), this.versions.get(b.id), hidden)); + return this.all().map((b) => infoOf(b, this.icons.get(b.id), this.versions.get(b.id), hidden, this.debuggable)); } private changed(): void { diff --git a/src/main/browsers/BrowserRegistry/infoOf.ts b/src/main/browsers/BrowserRegistry/infoOf.ts index ec7a838..eed5942 100644 --- a/src/main/browsers/BrowserRegistry/infoOf.ts +++ b/src/main/browsers/BrowserRegistry/infoOf.ts @@ -2,6 +2,6 @@ import type { BrowserInfo } from '../../../shared/types'; import type { FoundBrowser } from '../types'; /** What the UI is told of a browser: never its command or paths. */ -export function infoOf(browser: FoundBrowser, icon: string | null | undefined, version: string | null | undefined, hidden: ReadonlySet): BrowserInfo { - return { id: browser.id, name: browser.name, engine: browser.engine, version: version ?? null, icon: icon ?? null, added: browser.added, hidden: hidden.has(browser.id) }; +export function infoOf(browser: FoundBrowser, icon: string | null | undefined, version: string | null | undefined, hidden: ReadonlySet, debuggable: ReadonlySet): BrowserInfo { + return { id: browser.id, name: browser.name, engine: browser.engine, version: version ?? null, icon: icon ?? null, added: browser.added, hidden: hidden.has(browser.id), debuggable: debuggable.has(browser.id) }; } diff --git a/src/main/browsers/driven/DrivenBase.ts b/src/main/browsers/driven/DrivenBase.ts index 30b4dda..df9c21b 100644 --- a/src/main/browsers/driven/DrivenBase.ts +++ b/src/main/browsers/driven/DrivenBase.ts @@ -20,7 +20,7 @@ export abstract class DrivenBase { ) {} list(): DrivenBrowser { - return this.tabs.described(this.browser, this.version); + return this.tabs.described(this.deps.listedAs, this.browser, this.version); } /** diff --git a/src/main/browsers/driven/DrivenBrowsers.ts b/src/main/browsers/driven/DrivenBrowsers.ts index 024ccfe..15f87ab 100644 --- a/src/main/browsers/driven/DrivenBrowsers.ts +++ b/src/main/browsers/driven/DrivenBrowsers.ts @@ -1,11 +1,16 @@ +import { homedir } from 'node:os'; import type { AppEvent, CaptureArea, DrivenBrowser, DrivenEngine, ShotBrowser } from '../../../shared/types'; import { HTTP_URL } from '../../constants'; import type { PageDesign } from '../../overlay'; import type { BrowserCapture, CapturedImage, Viewport } from '../../shots/capture'; import { PAGE_AREAS } from '../../shots/constants'; import type { FoundBrowser } from '../types'; +import { captureEvery } from './captureEvery'; import { connectChromium } from './chromium/connectChromium'; import { RELOAD_DEBOUNCE_MS } from './constants'; +import { DriverPool } from './DriverPool'; +import { EVERYDAY_KEY_SUFFIX, EVERYDAY_NAME_SUFFIX } from './everyday/constants'; +import { connectEverydayChrome } from './everyday/connectEverydayChrome'; import { connectFirefox } from './firefox/connectFirefox'; import { shotBrowser } from './shotBrowser'; import type { ConnectDriver, DrivenBrowsersDeps, Driver } from './types'; @@ -17,77 +22,76 @@ const DRIVERS: Readonly> = { chromium: conne type AppEventReactions = Partial void>>; /** - * The browsers the app drives (Chromium ones and Firefox), one per installed browser, each with a profile of the app's - * own: launched (or reached again) when an address is first opened in one with the workspace's changes, and kept in - * step with them. Every change is announced as `driven-browsers-changed`. + * The browsers the app drives (Chromium ones and Firefox, each with a profile of the app's own, and your everyday + * Chrome when remote debugging is on for it): launched or reached when an address is first opened in one with the + * workspace's changes, and kept in step with them. Every change is announced as `driven-browsers-changed`. */ export class DrivenBrowsers { - private readonly driven = new Map(); - private readonly starting = new Map>(); + private readonly pool = new DriverPool(); private reloadTimer: ReturnType | undefined; /** The design over the app's page, if any: driven tabs have it too. */ private design: PageDesign | null = null; private readonly reactions: AppEventReactions = { 'overrides-changed': () => this.served(), 'rules-changed': () => this.served(), - 'settings-changed': () => void Promise.all(this.all().map((d) => d.applySettings())), + 'settings-changed': () => void Promise.all(this.pool.all().map((d) => d.applySettings())), }; constructor(private readonly deps: DrivenBrowsersDeps) {} list(): DrivenBrowser[] { - return this.all().map((d) => d.list()); + return this.pool.all().map((d) => d.list()); } /** The driven browsers with their tabs' titles and addresses as they are now. */ async read(): Promise { - await Promise.all(this.all().map((d) => d.readTabs())); + await Promise.all(this.pool.all().map((d) => d.readTabs())); return this.list(); } - /** Opens an http(s) address in a Chromium browser with the workspace's changes, launching it if need be. */ - async open(id: string, url: string): Promise { + /** + * Opens an http(s) address in a browser with the workspace's changes: with a profile of the app's own (launched if + * need be), or in your everyday Chromium browser (`everyday`), remote debugging turned on for it. + */ + async open(id: string, url: string, everyday = false): Promise { if (!HTTP_URL.test(url)) throw new Error('Only http(s) pages open in another browser'); const browser = await this.deps.registry.get(id); - const connect = Object.hasOwn(DRIVERS, browser.engine) ? DRIVERS[browser.engine as DrivenEngine] : undefined; - if (!connect) throw new Error(`${browser.name} can't be served your changes: only Chromium browsers and Firefox can`); - await (await this.reach(browser, connect)).open(url); + const own = Object.hasOwn(DRIVERS, browser.engine) ? DRIVERS[browser.engine as DrivenEngine] : undefined; + const connect = everyday ? (browser.engine === 'chromium' ? connectEverydayChrome : undefined) : own; + if (!connect) throw new Error(`${browser.name} can't be served your changes: only Chromium browsers${everyday ? '' : ' and Firefox'} can`); + const listedAs = everyday ? { id: `${id}${EVERYDAY_KEY_SUFFIX}`, name: `${browser.name}${EVERYDAY_NAME_SUFFIX}`, everyday } : { id, name: browser.name, everyday }; + const driver = await this.pool.reach(listedAs.id, () => this.connect(browser, listedAs, connect)); + this.changed(); + await driver.open(url); } - activate(browserId: string, tabId: string): Promise { - return this.get(browserId).activate(tabId); + activate(key: string, tabId: string): Promise { + return this.pool.get(key).activate(tabId); } /** Captures a tab of a driven browser, with the address it showed and the browser it was taken in. */ - async capture(browserId: string, tabId: string, area: unknown): Promise<{ image: CapturedImage; url: string; browser: ShotBrowser }> { + async capture(key: string, tabId: string, area: unknown): Promise<{ image: CapturedImage; url: string; browser: ShotBrowser }> { if (!PAGE_AREAS.has(area)) throw new Error('Invalid capture area'); - const driven = this.get(browserId); + const driven = this.pool.get(key); return { ...(await driven.capture(tabId, area as Exclude)), browser: shotBrowser(driven) }; } /** Captures the whole page at `url` in every driven browser at once, laid out in `viewport`; one that fails says why. */ captureAt(url: string, viewport: Viewport): Promise { - return Promise.all( - this.all().map((driven) => - driven.captureAt(url, viewport).then( - (taken) => ({ ...taken, browser: shotBrowser(driven) }), - (err: unknown) => ({ browser: driven.browser.name, reason: err instanceof Error ? err.message : String(err) }), - ), - ), - ); + return captureEvery(this.pool.all(), url, viewport); } /** Stops serving the workspace's changes in a browser; it stays open. */ - stop(browserId: string): void { - this.get(browserId).stop(); - this.driven.delete(browserId); + stop(key: string): void { + this.pool.get(key).stop(); + this.pool.remove(key); this.changed(); } /** Lays the design over the app's page over every driven tab too (null: takes it off), and over tabs opened later. */ async setDesign(design: PageDesign | null): Promise { this.design = design; - await Promise.all(this.all().map((d) => d.setDesign(design).catch(() => undefined))); + await Promise.all(this.pool.all().map((d) => d.setDesign(design).catch(() => undefined))); } /** Told every app event: overrides, rules and settings that change are served in the driven tabs too. */ @@ -98,47 +102,26 @@ export class DrivenBrowsers { /** Lets go of every driven browser (the app is quitting); they stay open. */ dispose(): void { clearTimeout(this.reloadTimer); - for (const driven of this.all()) driven.stop(); - this.driven.clear(); - } - - private all(): Driver[] { - return [...this.driven.values()]; + for (const driven of this.pool.clear()) driven.stop(); } - private get(id: string): Driver { - const driven = this.driven.get(id); - if (!driven) throw new Error("That browser isn't open with your changes any more"); - return driven; - } - - /** The driven browser for an installed one: already connected, connecting, or launched now. */ - private reach(browser: FoundBrowser, connect: ConnectDriver): Promise { - const driven = this.driven.get(browser.id); - if (driven) return Promise.resolve(driven); - const starting = this.starting.get(browser.id) ?? this.connect(browser, connect).finally(() => this.starting.delete(browser.id)); - this.starting.set(browser.id, starting); - return starting; - } - - private async connect(browser: FoundBrowser, connect: ConnectDriver): Promise { + private async connect(browser: FoundBrowser, listedAs: { id: string; name: string; everyday: boolean }, connect: ConnectDriver): Promise { const closed = () => { - this.driven.delete(browser.id); + this.pool.remove(listedAs.id); this.changed(); }; - const driven = await connect(browser, { sources: this.deps.sources, userData: this.deps.userData, changed: () => this.changed(), closed }); + const { sources, userData } = this.deps; + const driven = await connect(browser, { sources, userData, home: this.deps.home ?? homedir(), listedAs, changed: () => this.changed(), closed }); await driven.setDesign(this.design).catch(() => undefined); - this.driven.set(browser.id, driven); - this.changed(); return driven; } /** Overrides or rules changed: each tab intercepts what they now need, and reloads (after a burst, once) if the settings say so. */ private served(): void { - void Promise.all(this.all().map((d) => d.refresh())); - if (!this.deps.sources.settings.get().autoReloadOnSave || this.driven.size === 0) return; + void Promise.all(this.pool.all().map((d) => d.refresh())); + if (!this.deps.sources.settings.get().autoReloadOnSave || this.pool.size === 0) return; clearTimeout(this.reloadTimer); - this.reloadTimer = setTimeout(() => void Promise.all(this.all().map((d) => d.reload())), RELOAD_DEBOUNCE_MS); + this.reloadTimer = setTimeout(() => void Promise.all(this.pool.all().map((d) => d.reload())), RELOAD_DEBOUNCE_MS); } private changed(): void { diff --git a/src/main/browsers/driven/DrivenTabs.ts b/src/main/browsers/driven/DrivenTabs.ts index fd0e2f4..2ebfce4 100644 --- a/src/main/browsers/driven/DrivenTabs.ts +++ b/src/main/browsers/driven/DrivenTabs.ts @@ -2,7 +2,7 @@ import type { DrivenBrowser, DrivenTab } from '../../../shared/types'; import { HTTP_URL } from '../../constants'; import type { FoundBrowser } from '../types'; import { START_URL } from './constants'; -import type { KeptTab, TabRead } from './types'; +import type { DriverDeps, KeptTab, TabRead } from './types'; /** A driven browser's tabs, in the order they were found; waits for the tab a new one becomes. */ export class DrivenTabs { @@ -76,8 +76,8 @@ export class DrivenTabs { } /** The browser with these tabs, as listed. */ - described({ id, name }: Pick, version: string | null): DrivenBrowser { - return { id, name, version, tabs: this.list() }; + described(listedAs: DriverDeps['listedAs'], browser: Pick, version: string | null): DrivenBrowser { + return { ...listedAs, browserId: browser.id, version, tabs: this.list() }; } /** Forgets every tab, and returns them. */ diff --git a/src/main/browsers/driven/DriverPool.ts b/src/main/browsers/driven/DriverPool.ts new file mode 100644 index 0000000..a899ef6 --- /dev/null +++ b/src/main/browsers/driven/DriverPool.ts @@ -0,0 +1,48 @@ +import type { Driver } from './types'; + +/** The drivers of the browsers driven now, by key, and the ones still connecting: each connected once. */ +export class DriverPool { + private readonly drivers = new Map(); + private readonly starting = new Map>(); + + all(): Driver[] { + return [...this.drivers.values()]; + } + + get size(): number { + return this.drivers.size; + } + + get(key: string): Driver { + const driver = this.drivers.get(key); + if (!driver) throw new Error("That browser isn't open with your changes any more"); + return driver; + } + + /** The driver for `key`: connected, still connecting, or connected now by `start`. */ + reach(key: string, start: () => Promise): Promise { + const driver = this.drivers.get(key); + if (driver) return Promise.resolve(driver); + const starting = + this.starting.get(key) ?? + start() + .then((started) => { + this.drivers.set(key, started); + return started; + }) + .finally(() => this.starting.delete(key)); + this.starting.set(key, starting); + return starting; + } + + remove(key: string): void { + this.drivers.delete(key); + } + + /** Forgets every driver, and returns them. */ + clear(): Driver[] { + const all = this.all(); + this.drivers.clear(); + return all; + } +} diff --git a/src/main/browsers/driven/captureEvery.ts b/src/main/browsers/driven/captureEvery.ts new file mode 100644 index 0000000..e51e0a3 --- /dev/null +++ b/src/main/browsers/driven/captureEvery.ts @@ -0,0 +1,15 @@ +import type { BrowserCapture, Viewport } from '../../shots/capture'; +import { shotBrowser } from './shotBrowser'; +import type { Driver } from './types'; + +/** Captures the whole page at `url` in every driver's browser at once, laid out in `viewport`; one that fails says why. */ +export function captureEvery(drivers: readonly Driver[], url: string, viewport: Viewport): Promise { + return Promise.all( + drivers.map((driver) => + driver.captureAt(url, viewport).then( + (taken) => ({ ...taken, browser: shotBrowser(driver) }), + (err: unknown) => ({ browser: driver.browser.name, reason: err instanceof Error ? err.message : String(err) }), + ), + ), + ); +} diff --git a/src/main/browsers/driven/chromium/DrivenChromium.ts b/src/main/browsers/driven/chromium/DrivenChromium.ts index 06fe047..7cf12af 100644 --- a/src/main/browsers/driven/chromium/DrivenChromium.ts +++ b/src/main/browsers/driven/chromium/DrivenChromium.ts @@ -28,7 +28,7 @@ export class DrivenChromium extends DrivenBase implements Driver constructor( browser: FoundBrowser, version: string | null, - private readonly connection: CdpConnection, + protected readonly connection: CdpConnection, deps: DriverDeps, ) { super(browser, version, deps); @@ -36,6 +36,12 @@ export class DrivenChromium extends DrivenBase implements Driver /** Attaches to the browser's tabs, the ones open now and every one opened later. */ async start(): Promise { + await this.listen(); + await this.connection.send(CDP.Target.setAutoAttach, { ...PAGE_ATTACH }); + } + + /** Follows the browser's tabs attached (and their address and title), and the connection closing. */ + protected async listen(): Promise { const events: Partial void>> = { [CDP.Target.attachedToTarget]: (p: AttachedPage) => this.attached(p), [CDP.Target.detachedFromTarget]: (p: { sessionId: string }) => this.detached(p.sessionId), @@ -49,7 +55,6 @@ export class DrivenChromium extends DrivenBase implements Driver this.connection.onClose(() => this.closed()), ); await this.connection.send(CDP.Target.setDiscoverTargets, { discover: true }); - await this.connection.send(CDP.Target.setAutoAttach, { ...PAGE_ATTACH }); } /** Opens an address in the blank tab the browser started on, or else in a new tab, and brings it to the front. */ @@ -91,9 +96,10 @@ export class DrivenChromium extends DrivenBase implements Driver this.connection.close(); } - private attached(p: AttachedPage): void { + protected attached(p: AttachedPage): void { // Only tabs are asked for; anything else is let go of as it came. if (p.targetInfo.type !== PAGE_TARGET) return letTargetGo(this.connection, p.sessionId); + if (this.tabs.has(p.targetInfo.targetId)) return; const tab = attachTab(this.connection, p, this.deps.sources); tab.transport.on(CDP.Page.loadEventFired, () => void this.readTabs([tab.info.id])); this.tabs.add(tab); @@ -127,7 +133,7 @@ export class DrivenChromium extends DrivenBase implements Driver return captureTabAt(tab, viewport); } - private async newTab(): Promise { + protected async newTab(): Promise { const { targetId } = await this.connection.send<{ targetId: string }>(CDP.Target.createTarget, { url: START_URL }); return withTimeout(this.tabs.arrival(targetId), NEW_TAB_TIMEOUT_MS, 'Opening a tab'); } diff --git a/src/main/browsers/driven/chromium/DrivenEverydayChrome.ts b/src/main/browsers/driven/chromium/DrivenEverydayChrome.ts new file mode 100644 index 0000000..1dfc991 --- /dev/null +++ b/src/main/browsers/driven/chromium/DrivenEverydayChrome.ts @@ -0,0 +1,23 @@ +import { CDP } from '../../../engine/constants'; +import { START_URL } from '../constants'; +import { PAGE_TARGET } from './constants'; +import { DrivenChromium } from './DrivenChromium'; +import type { DrivenTabState } from './types'; + +/** + * Your everyday Chromium browser, remote debugging turned on for it: only the tabs the app opens there are attached and + * served the workspace's changes. Your own tabs are neither listed nor touched, and letting go of it leaves it as it was. + */ +export class DrivenEverydayChrome extends DrivenChromium { + override async start(): Promise { + await this.listen(); + } + + protected override async newTab(): Promise { + const { targetId } = await this.connection.send<{ targetId: string }>(CDP.Target.createTarget, { url: START_URL }); + const { sessionId } = await this.connection.send<{ sessionId: string }>(CDP.Target.attachToTarget, { targetId, flatten: true }); + // Its interception is set up before anything loads in it (the attach event, if it came first, did the same). + this.attached({ sessionId, targetInfo: { targetId, type: PAGE_TARGET, url: START_URL, title: '' } }); + return this.tabs.get(targetId); + } +} diff --git a/src/main/browsers/driven/everyday/connectEverydayChrome.ts b/src/main/browsers/driven/everyday/connectEverydayChrome.ts new file mode 100644 index 0000000..547e8c6 --- /dev/null +++ b/src/main/browsers/driven/everyday/connectEverydayChrome.ts @@ -0,0 +1,27 @@ +import { CDP } from '../../../engine/constants'; +import { withTimeout } from '../../../engine/PageInterception'; +import { CdpConnection } from '../../../engine/websocketTransport'; +import type { FoundBrowser } from '../../types'; +import { PRODUCT_VERSION } from '../chromium/constants'; +import { DrivenEverydayChrome } from '../chromium/DrivenEverydayChrome'; +import type { Driver, DriverDeps } from '../types'; +import { ALLOW_TIMEOUT_MS } from './constants'; +import { reachableEveryday } from './reachableEveryday'; + +/** + * Drives your everyday Chromium browser with the workspace's changes, over the debugging port turned on for it + * (chrome://inspect/#remote-debugging): the browser asks you to allow the connection first. + */ +export async function connectEverydayChrome(browser: FoundBrowser, deps: DriverDeps): Promise { + const address = await reachableEveryday(browser, deps.home); + if (!address) throw new Error(`Turn on remote debugging in ${browser.name} first: chrome://inspect/#remote-debugging`); + const reached = (async () => { + const connection = await CdpConnection.connect(address); + const { product } = await connection.send<{ product: string }>(CDP.Browser.getVersion); + return { connection, version: PRODUCT_VERSION.exec(product)?.[1] ?? null }; + })(); + const { connection, version } = await withTimeout(reached, ALLOW_TIMEOUT_MS, `Waiting for ${browser.name} to allow the connection`); + const driver = new DrivenEverydayChrome(browser, version, connection, deps); + await driver.start(); + return driver; +} diff --git a/src/main/browsers/driven/everyday/constants.ts b/src/main/browsers/driven/everyday/constants.ts new file mode 100644 index 0000000..5e73db1 --- /dev/null +++ b/src/main/browsers/driven/everyday/constants.ts @@ -0,0 +1,33 @@ +/** + * Where each Chromium browser keeps its everyday profile, by the words its name or program has (first match first): + * under `~/.config` on Linux, `~/Library/Application Support` on macOS, `%LOCALAPPDATA%` on Windows. With remote + * debugging turned on for it (Chrome 144's chrome://inspect/#remote-debugging), it writes its address there. + */ +export const EVERYDAY_PROFILES: ReadonlyArray<{ pattern: RegExp; linux: string; darwin: string; win32: string }> = [ + { pattern: /chrome.*beta/i, linux: 'google-chrome-beta', darwin: 'Google/Chrome Beta', win32: 'Google/Chrome Beta/User Data' }, + { pattern: /chrome.*(unstable|dev)/i, linux: 'google-chrome-unstable', darwin: 'Google/Chrome Dev', win32: 'Google/Chrome Dev/User Data' }, + { pattern: /canary/i, linux: 'google-chrome-canary', darwin: 'Google/Chrome Canary', win32: 'Google/Chrome SxS/User Data' }, + { pattern: /chromium/i, linux: 'chromium', darwin: 'Chromium', win32: 'Chromium/User Data' }, + { pattern: /chrome/i, linux: 'google-chrome', darwin: 'Google/Chrome', win32: 'Google/Chrome/User Data' }, + { pattern: /edge/i, linux: 'microsoft-edge', darwin: 'Microsoft Edge', win32: 'Microsoft/Edge/User Data' }, + { pattern: /brave/i, linux: 'BraveSoftware/Brave-Browser', darwin: 'BraveSoftware/Brave-Browser', win32: 'BraveSoftware/Brave-Browser/User Data' }, + { pattern: /vivaldi/i, linux: 'vivaldi', darwin: 'Vivaldi', win32: 'Vivaldi/User Data' }, + { pattern: /opera/i, linux: 'opera', darwin: 'com.operasoftware.Opera', win32: 'Opera Software/Opera Stable' }, + { pattern: /\barc\b/i, linux: 'arc', darwin: 'Arc/User Data', win32: 'Arc/User Data' }, +]; + +/** Each system's folder of profiles, from the home folder (Windows: its local app data). */ +export const PROFILES_ROOT: Partial> = { linux: '.config', darwin: 'Library/Application Support', win32: 'AppData/Local' }; + +/** Where a Snap's (`~/snap//common/`) and a Flatpak's (`~/.var/app//config/`) everyday profiles are. */ +export const SANDBOXED_PROFILES = { snapCommon: 'common', flatpakData: '.var/app', flatpakConfig: 'config' } as const; + +/** How long a debugging port gets to take a connection before it counts as closed (the file can outlive the browser). */ +export const LISTENING_TIMEOUT_MS = 500; + +/** How long connecting may take: Chrome asks you to allow it first. */ +export const ALLOW_TIMEOUT_MS = 60_000; + +/** A driven everyday browser's key, and its name, beside the installed browser's. */ +export const EVERYDAY_KEY_SUFFIX = '#everyday'; +export const EVERYDAY_NAME_SUFFIX = ' · your profile'; diff --git a/src/main/browsers/driven/everyday/everydayProfileDir.ts b/src/main/browsers/driven/everyday/everydayProfileDir.ts new file mode 100644 index 0000000..5980f30 --- /dev/null +++ b/src/main/browsers/driven/everyday/everydayProfileDir.ts @@ -0,0 +1,23 @@ +import { basename, join } from 'node:path'; +import { SANDBOX_DIRS } from '../constants'; +import type { FoundBrowser } from '../../types'; +import { EVERYDAY_PROFILES, PROFILES_ROOT, SANDBOXED_PROFILES } from './constants'; + +/** + * The folder of a Chromium browser's everyday profile, by its name or program: in its system's usual place, or where + * its Snap or Flatpak keeps it. Null for a browser (or system) the app doesn't know it for. + */ +export function everydayProfileDir(browser: FoundBrowser, home: string, platform: NodeJS.Platform = process.platform): string | null { + const names = `${browser.name} ${browser.program ?? ''} ${browser.id}`; + const profile = EVERYDAY_PROFILES.find((p) => p.pattern.test(names)); + const root = PROFILES_ROOT[platform]; + if (!profile || !root || !(platform in profile)) return null; + const folder = profile[platform as keyof typeof profile] as string; + const program = browser.program ?? ''; + if (program.startsWith(SANDBOX_DIRS.snapBin)) return join(home, SANDBOX_DIRS.snap, basename(program), SANDBOXED_PROFILES.snapCommon, folder); + if (basename(program) === SANDBOX_DIRS.flatpak) { + const appId = browser.command.slice(browser.command.indexOf(SANDBOX_DIRS.flatpakRun) + 1).find((arg) => !arg.startsWith('-')); + if (appId) return join(home, SANDBOXED_PROFILES.flatpakData, appId, SANDBOXED_PROFILES.flatpakConfig, folder); + } + return join(home, root, folder); +} diff --git a/src/main/browsers/driven/everyday/isListening.ts b/src/main/browsers/driven/everyday/isListening.ts new file mode 100644 index 0000000..3362a66 --- /dev/null +++ b/src/main/browsers/driven/everyday/isListening.ts @@ -0,0 +1,19 @@ +import { connect } from 'node:net'; +import { LISTENING_TIMEOUT_MS } from './constants'; + +/** + * Whether something takes connections on a loopback port (a TCP connection opened and closed at once: nothing is sent, + * so Chrome doesn't ask you to allow anything). + */ +export function isListening(port: number): Promise { + return new Promise((resolve) => { + const socket = connect({ host: '127.0.0.1', port }); + const done = (listening: boolean) => { + socket.destroy(); + resolve(listening); + }; + socket.setTimeout(LISTENING_TIMEOUT_MS, () => done(false)); + socket.once('connect', () => done(true)); + socket.once('error', () => done(false)); + }); +} diff --git a/src/main/browsers/driven/everyday/reachableEveryday.ts b/src/main/browsers/driven/everyday/reachableEveryday.ts new file mode 100644 index 0000000..7fc487e --- /dev/null +++ b/src/main/browsers/driven/everyday/reachableEveryday.ts @@ -0,0 +1,15 @@ +import type { FoundBrowser } from '../../types'; +import { readActivePort } from '../chromium/readActivePort'; +import { everydayProfileDir } from './everydayProfileDir'; +import { isListening } from './isListening'; + +/** + * The debugging address of a Chromium browser's everyday profile, while remote debugging is on for it and it runs + * (its port takes connections); null otherwise. + */ +export async function reachableEveryday(browser: FoundBrowser, home: string, platform: NodeJS.Platform = process.platform): Promise { + const dir = browser.engine === 'chromium' ? everydayProfileDir(browser, home, platform) : null; + const address = dir ? await readActivePort(dir) : null; + const port = address ? Number(new URL(address).port) : 0; + return port && (await isListening(port)) ? address : null; +} diff --git a/src/main/browsers/driven/shotBrowser.ts b/src/main/browsers/driven/shotBrowser.ts index ba565de..5d4a282 100644 --- a/src/main/browsers/driven/shotBrowser.ts +++ b/src/main/browsers/driven/shotBrowser.ts @@ -3,6 +3,6 @@ import type { Driver } from './types'; /** A driven browser, as the browser a capture was taken in. */ export function shotBrowser(driver: Driver): ShotBrowser { - const { id, name, version } = driver.list(); - return { id, name, version }; + const { browserId, name, version } = driver.list(); + return { id: browserId, name, version }; } diff --git a/src/main/browsers/driven/types.ts b/src/main/browsers/driven/types.ts index e21b969..ad4b568 100644 --- a/src/main/browsers/driven/types.ts +++ b/src/main/browsers/driven/types.ts @@ -70,6 +70,10 @@ export interface TabDesigns { } export interface DriverDeps { + /** How the driver lists itself: its key among the driven browsers, and its name. */ + listedAs: { id: string; name: string; everyday: boolean }; + /** The home folder, where everyday profiles are. */ + home: string; sources: InterceptionSources; /** The app's data folder, where driven browsers keep their profiles. */ userData: string; @@ -96,5 +100,7 @@ export interface DrivenBrowsersDeps { sources: InterceptionSources; /** The app's data folder, where driven browsers keep their profiles. */ userData: string; + /** The home folder, where everyday profiles are (the user's by default). */ + home?: string; send(event: AppEvent): void; } diff --git a/src/main/browsers/types.ts b/src/main/browsers/types.ts index 8ffb6a9..c5284b2 100644 --- a/src/main/browsers/types.ts +++ b/src/main/browsers/types.ts @@ -27,4 +27,6 @@ export interface BrowserRegistryDeps { send(event: AppEvent): void; /** Looks for the installed browsers (the system's own way by default; tests hand their own). */ find?: () => Promise; + /** The home folder, where everyday profiles are (the user's by default). */ + home?: string; } diff --git a/src/main/ipc/registerBrowserIpc.ts b/src/main/ipc/registerBrowserIpc.ts index f265037..2a1953e 100644 --- a/src/main/ipc/registerBrowserIpc.ts +++ b/src/main/ipc/registerBrowserIpc.ts @@ -29,10 +29,10 @@ export function registerBrowserIpc(handle: IpcHandle, handlePage: IpcHandle, { w assertString(url, 'url'); return browsers.open(id, url); }); - handlePage(IPC_CHANNEL.openWithChanges, (id: unknown, url: unknown) => { + handlePage(IPC_CHANNEL.openWithChanges, (id: unknown, url: unknown, everyday: unknown) => { assertString(id, 'id'); assertString(url, 'url'); - return driven.open(id, url); + return driven.open(id, url, everyday === true); }); handlePage(IPC_CHANNEL.listDriven, () => driven.read()); handlePage(IPC_CHANNEL.listEverydayTabs, () => listEverydayTabs()); diff --git a/src/preload/browsersApi.ts b/src/preload/browsersApi.ts index b7c18ec..4d31197 100644 --- a/src/preload/browsersApi.ts +++ b/src/preload/browsersApi.ts @@ -9,7 +9,7 @@ export const browsersApi: BrowsersApi = { addBrowser: () => ipcRenderer.invoke(IPC_CHANNEL.addBrowser), removeBrowser: (id) => ipcRenderer.invoke(IPC_CHANNEL.removeBrowser, id), setBrowserHidden: (id, hidden) => ipcRenderer.invoke(IPC_CHANNEL.setBrowserHidden, id, hidden), - openWithChanges: (id, url) => ipcRenderer.invoke(IPC_CHANNEL.openWithChanges, id, url), + openWithChanges: (id, url, everyday) => ipcRenderer.invoke(IPC_CHANNEL.openWithChanges, id, url, everyday === true), listDriven: () => ipcRenderer.invoke(IPC_CHANNEL.listDriven), activateTab: (browserId, tabId) => ipcRenderer.invoke(IPC_CHANNEL.activateTab, browserId, tabId), stopDriving: (browserId) => ipcRenderer.invoke(IPC_CHANNEL.stopDriving, browserId), diff --git a/src/renderer/src/features/browser/open-in-browser/model/openWithChanges.ts b/src/renderer/src/features/browser/open-in-browser/model/openWithChanges.ts index 4c2a951..daaa04a 100644 --- a/src/renderer/src/features/browser/open-in-browser/model/openWithChanges.ts +++ b/src/renderer/src/features/browser/open-in-browser/model/openWithChanges.ts @@ -3,11 +3,15 @@ import { api, errorMessage } from '@/shared/api'; import { toast } from '@/shared/ui/toast'; import { usePageStore } from '@/entities/page'; -/** Opens the page shown in a Chromium browser with a profile of the app's own, served the workspace's overrides and rules. */ -export async function openWithChanges(browser: Pick): Promise { +/** + * Opens the page shown in a browser the app drives, served the workspace's overrides and rules: with a profile of the + * app's own, or in your everyday one (`everyday`: a Chromium browser with remote debugging turned on, which asks you to + * allow it). + */ +export async function openWithChanges(browser: Pick, everyday = false): Promise { try { - await api.openWithChanges(browser.id, usePageStore.getState().page.url); + await api.openWithChanges(browser.id, usePageStore.getState().page.url, everyday); } catch (err) { - toast({ title: `Could not open ${browser.name} with your changes`, description: errorMessage(err), tone: 'danger' }); + toast({ title: `Could not open ${everyday ? `your ${browser.name}` : browser.name} with your changes`, description: errorMessage(err), tone: 'danger' }); } } diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx index 0e9e497..ead3d7e 100644 --- a/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserMenu.tsx @@ -35,9 +35,9 @@ export function BrowserMenu({ onClose, onShowSettings }: BrowserMenuProps) { onClose(); void openInBrowser(browser); }; - const openChanged = (browser: BrowserInfo) => { + const openChanged = (browser: BrowserInfo, everyday: boolean) => { onClose(); - void openWithChanges(browser); + void openWithChanges(browser, everyday); }; return ( @@ -60,7 +60,7 @@ export function BrowserMenu({ onClose, onShowSettings }: BrowserMenuProps) { {onWeb ? null :

Open a website first, then open it in another browser from here.

}
{matches.map((browser) => ( - d.id === browser.id)} onOpen={open} onOpenWithChanges={openChanged} /> + d.browserId === browser.id)} onOpen={open} onOpenWithChanges={openChanged} /> ))} {!loaded ? : null} {loaded && !matches.length ? ( diff --git a/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx b/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx index f6be7b5..96285fb 100644 --- a/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx +++ b/src/renderer/src/features/browser/open-in-browser/ui/BrowserRow.tsx @@ -10,12 +10,14 @@ export interface BrowserRowProps { /** The app drives it with the workspace's changes (it is marked). */ driven: boolean; onOpen(browser: BrowserInfo): void; - onOpenWithChanges(browser: BrowserInfo): void; + /** Opens it with the workspace's changes: with a profile of the app's own, or your everyday one (`everyday`). */ + onOpenWithChanges(browser: BrowserInfo, everyday: boolean): void; } /** * A browser in the menu: its icon, name and version; choosing it opens the page there. A Chromium browser or Firefox - * also offers opening it with the workspace's changes, in a profile of the app's own. + * also offers opening it with the workspace's changes, in a profile of the app's own; a Chromium browser running with + * remote debugging turned on, in your everyday profile too. */ export function BrowserRow({ browser, disabled, driven, onOpen, onOpenWithChanges }: BrowserRowProps) { return ( @@ -37,8 +39,19 @@ export function BrowserRow({ browser, disabled, driven, onOpen, onOpenWithChange label={`Open in ${browser.name} with your changes`} size="sm" disabled={disabled} - onClick={() => onOpenWithChanges(browser)} + onClick={() => onOpenWithChanges(browser, false)} data-testid="browser-open-with-changes" + className={browser.debuggable ? undefined : 'mr-1'} + /> + ) : null} + {browser.debuggable ? ( + onOpenWithChanges(browser, true)} + data-testid="browser-open-everyday" className="mr-1" /> ) : null} diff --git a/src/renderer/src/features/browser/open-in-browser/ui/DrivenSection.tsx b/src/renderer/src/features/browser/open-in-browser/ui/DrivenSection.tsx index 5ec731c..7a27622 100644 --- a/src/renderer/src/features/browser/open-in-browser/ui/DrivenSection.tsx +++ b/src/renderer/src/features/browser/open-in-browser/ui/DrivenSection.tsx @@ -25,7 +25,7 @@ export function DrivenSection({ query, onClose }: DrivenSectionProps) { return (
- + {browser.name}, with your changes void stopDriving(browser)} />
diff --git a/src/renderer/src/widgets/command-palette/ui/browserItems.ts b/src/renderer/src/widgets/command-palette/ui/browserItems.ts index bb160d0..34d8069 100644 --- a/src/renderer/src/widgets/command-palette/ui/browserItems.ts +++ b/src/renderer/src/widgets/command-palette/ui/browserItems.ts @@ -4,9 +4,10 @@ import type { CommandItem } from '@/shared/ui/command-palette'; import { canDrive } from '@/entities/browser'; import { openInBrowser, openWithChanges } from '@/features/browser/open-in-browser'; -/** Palette items' id prefixes: opening the page in a browser, and in one the app drives, with the workspace's changes. */ +/** Palette items' id prefixes: opening the page in a browser, in one the app drives or in your own, with the workspace's changes. */ const OPEN_IN_PREFIX = 'open-in:'; const OPEN_CHANGED_PREFIX = 'open-changed:'; +const OPEN_EVERYDAY_PREFIX = 'open-everyday:'; /** Opening the page in each browser offered, and in each one the app drives with the workspace's changes, while it is on the web. */ export function browserItems(browsers: BrowserInfo[], onWeb: boolean): CommandItem[] { @@ -30,5 +31,16 @@ export function browserItems(browsers: BrowserInfo[], onWeb: boolean): CommandIt }, ] : []), + ...(browser.debuggable + ? [ + { + id: `${OPEN_EVERYDAY_PREFIX}${browser.id}`, + label: `Use your own ${browser.name} with your changes`, + icon: icons.LiveIcon, + keywords: ['browser', 'everyday', 'profile', 'remote debugging', 'changes', browser.engine], + onSelect: () => void openWithChanges(browser, true), + }, + ] + : []), ]); } diff --git a/src/shared/types/browsers.ts b/src/shared/types/browsers.ts index b1d90f9..e15fffb 100644 --- a/src/shared/types/browsers.ts +++ b/src/shared/types/browsers.ts @@ -22,6 +22,8 @@ export interface BrowserInfo { added: boolean; /** Turned off in Settings › Browsers: not offered beside the address bar. */ hidden: boolean; + /** A Chromium browser whose everyday profile runs with remote debugging turned on: it can be driven as it is. */ + debuggable: boolean; } /** A tab of your everyday browser (one the app didn't launch), as its session has it. */ @@ -49,8 +51,12 @@ export interface DrivenTab { /** A browser the app launched with a profile of its own, and serves the workspace's overrides and rules in. */ export interface DrivenBrowser { - /** The installed browser's id. */ + /** Its key among the driven browsers: the installed browser's id, with a suffix for its everyday profile. */ id: string; + /** The installed browser's id. */ + browserId: string; + /** It is your everyday profile (remote debugging turned on for it), not one of the app's own. */ + everyday: boolean; name: string; version: string | null; tabs: DrivenTab[]; diff --git a/src/shared/types/browsersApi.ts b/src/shared/types/browsersApi.ts index 3ef13d4..53dfc04 100644 --- a/src/shared/types/browsersApi.ts +++ b/src/shared/types/browsersApi.ts @@ -13,10 +13,11 @@ export interface BrowsersApi { /** Offers a browser beside the address bar again, or stops offering it. */ setBrowserHidden(id: string, hidden: boolean): Promise; /** - * Opens an http(s) address in a Chromium browser with a profile of the app's own, serving the workspace's overrides - * and rules in its tabs (launching it, or reaching it when the app launched it before and it is still open). + * Opens an http(s) address in a Chromium browser or Firefox with a profile of the app's own, serving the workspace's + * overrides and rules in its tabs (launching it, or reaching it when the app launched it before and it is still open); + * with `everyday`, in your everyday Chromium browser, remote debugging turned on for it (only the tabs the app opens). */ - openWithChanges(id: string, url: string): Promise; + openWithChanges(id: string, url: string, everyday?: boolean): Promise; /** The browsers the app drives, with their tabs. */ listDriven(): Promise; /** Brings a tab of a driven browser to the front. */ diff --git a/test/e2e/driven.e2e.test.ts b/test/e2e/driven.e2e.test.ts index 9d56a46..d36d621 100644 --- a/test/e2e/driven.e2e.test.ts +++ b/test/e2e/driven.e2e.test.ts @@ -2,8 +2,10 @@ * A Chromium browser driven with the workspace's changes, in the built app on Linux: a real Chromium installed as a * launcher is offered "with your changes" in the browser menu; the page opens there served the workspace's override, * its tab is listed under the browser and captured into the shots, the page is captured here and there at once and - * the two compared, and letting go of it takes it off the menu. + * the two compared, and letting go of it takes it off the menu. Your own Chromium, remote debugging on for it, is + * offered too, and served your changes only in the tab the app opens. */ +import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; @@ -48,6 +50,7 @@ describe.skipIf(!built || !chromiumPath || process.platform !== 'linux')('A brow let dir: string; let app: ElectronApplication; let win: Page; + let everyday: ChildProcess | undefined; type Api = { consoleEditor: { listDriven(): Promise; listShots(): Promise; createOverride(input: object): Promise } }; const driven = () => win.evaluate(() => (window as unknown as Api).consoleEditor.listDriven()); @@ -76,7 +79,7 @@ describe.skipIf(!built || !chromiumPath || process.platform !== 'linux')('A brow app = await electron.launch({ args: [...sandboxArgs, root], cwd: root, - env: { ...process.env, CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, + env: { ...process.env, HOME: join(dir, 'home'), CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, }); win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); await win.waitForSelector('body[data-ready]'); @@ -94,6 +97,7 @@ describe.skipIf(!built || !chromiumPath || process.platform !== 'linux')('A brow const connection = address ? await CdpConnection.connect(address).catch(() => null) : null; await connection?.send('Browser.close').catch(() => undefined); connection?.close(); + everyday?.kill(); await app?.close(); await new Promise((r) => server?.close(r)); await rm(dir, { recursive: true, force: true, maxRetries: 5 }); @@ -151,5 +155,25 @@ describe.skipIf(!built || !chromiumPath || process.platform !== 'linux')('A brow await menu().getByRole('button', { name: 'Stop serving your changes in Test Chromium' }).click(); await expect.poll(() => driven()).toEqual([]); await expect.poll(() => menu().getByTestId('driven-browsers').count()).toBe(0); + await win.keyboard.press('Escape'); + await expect.poll(() => menu().count()).toBe(0); + }); + + it('uses your own Chromium, remote debugging on for it, with your changes: only in the tab it opens', async () => { + // Your everyday Chromium (its profile in the home folder), with a tab of your own. + const profile = join(dir, 'home/.config/chromium'); + mkdirSync(profile, { recursive: true }); + everyday = spawn(chromiumPath!, ['--headless=new', ...sandboxArgs, `--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', 'about:blank'], { stdio: 'ignore' }); + await waitFor(() => readActivePort(profile)); + await win.getByTestId('browser-menu-button').click(); + const row = menu().locator(`[data-testid="browser-row"][data-browser-id="${BROWSER_ID}"]`); + await row.getByTestId('browser-open-everyday').click(); + await expect.poll(async () => (await driven()).find((d) => d.everyday)?.tabs.map((t) => t.title), { timeout: 30_000 }).toEqual(['overridden']); + expect((await driven())[0]).toMatchObject({ id: `${BROWSER_ID}#everyday`, browserId: BROWSER_ID, name: 'Test Chromium · your profile' }); + await win.getByTestId('browser-menu-button').click(); + await menu().getByRole('button', { name: 'Stop serving your changes in Test Chromium · your profile' }).click(); + await expect.poll(() => driven()).toEqual([]); + // Still running, as it was. + expect(everyday.exitCode).toBeNull(); }); }); diff --git a/test/integration/everydayChrome.chromium.test.ts b/test/integration/everydayChrome.chromium.test.ts new file mode 100644 index 0000000..7137d07 --- /dev/null +++ b/test/integration/everydayChrome.chromium.test.ts @@ -0,0 +1,118 @@ +/** + * Your everyday Chromium browser, remote debugging turned on for it (Chrome 144's chrome://inspect/#remote-debugging): + * found through the address it writes in its everyday profile, listed as debuggable while it runs, driven without a + * launch, only in the tab the app opens (served the overrides), your own tab left alone, and let go of as it was. A + * Chromium started on a stand-in everyday profile with a debugging port plays the part. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { BrowserRegistry, DrivenBrowsers, type FoundBrowser } from '../../src/main/browsers'; +import { readActivePort } from '../../src/main/browsers/driven/chromium/readActivePort'; +import { reachableEveryday } from '../../src/main/browsers/driven/everyday/reachableEveryday'; +import type { InterceptionSources } from '../../src/main/browsers/driven/types'; +import { CdpConnection } from '../../src/main/engine/websocketTransport'; +import { BrowserStore } from '../../src/main/store/BrowserStore'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type AppEvent, type Override } from '../../src/shared/types'; +import { chromiumAvailable } from '../helpers/chromium'; + +async function waitFor(fn: () => T | undefined | false | Promise, timeout = 20_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!chromiumAvailable)('your everyday Chromium browser, remote debugging on', () => { + let server: Server; + let origin: string; + let home: string; + let profile: string; + let everyday: ChildProcess; + let driven: DrivenBrowsers; + const events: AppEvent[] = []; + const overrides: Override[] = []; + const sources = { store: { list: () => overrides, base: async () => '' }, rules: { list: () => [] }, settings: { get: () => DEFAULT_SETTINGS } } as unknown as InterceptionSources; + const browser: FoundBrowser = { id: 'desktop:google-chrome.desktop', name: 'Google Chrome', engine: 'chromium', command: ['/usr/bin/google-chrome-stable'], urlAt: 1, iconFile: null, app: null, program: '/usr/bin/google-chrome-stable', added: false }; + /** The page targets the browser has, as its own debugging port lists them. */ + const pages = async () => { + const connection = await CdpConnection.connect((await readActivePort(profile))!); + const { targetInfos } = await connection.send<{ targetInfos: Array<{ type: string; url: string; attached: boolean }> }>('Target.getTargets'); + connection.close(); + return targetInfos.filter((t) => t.type === 'page'); + }; + + beforeAll(async () => { + server = createServer((req, res) => { + const files: Record = { + '/page.html': ['text/html', 'upstream'], + '/app.js': ['text/javascript', "document.title = 'upstream';"], + }; + const file = files[new URL(req.url ?? '/', 'http://x').pathname]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + home = await mkdtemp(join(tmpdir(), 'console-editor-everyday-chrome-')); + profile = join(home, '.config/google-chrome'); + mkdirSync(profile, { recursive: true }); + const now = Date.now(); + overrides.push({ id: 'o1', kind: 'Script', sourceUrl: `${origin}/app.js`, match: defaultMatcherFor(`${origin}/app.js`), content: "document.title = 'overridden';", enabled: true, originalHash: null, createdAt: now, updatedAt: now }); + // "Your" Chrome: its everyday profile, remote debugging on, one tab of your own open. + const sandbox = process.getuid?.() === 0 ? ['--no-sandbox'] : []; + everyday = spawn(chromium.executablePath(), ['--headless=new', ...sandbox, `--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', `${origin}/page.html?mine`], { stdio: 'ignore' }); + await waitFor(() => readActivePort(profile)); + driven = new DrivenBrowsers({ registry: { get: async () => browser }, sources, userData: join(home, 'user-data'), home, send: (event) => events.push(event) }); + }); + + afterAll(async () => { + driven?.dispose(); + everyday?.kill(); + await new Promise((r) => server?.close(r)); + await rm(home, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('is found through the address it wrote in its everyday profile, and listed as debuggable while it runs', async () => { + expect(await reachableEveryday(browser, home, 'linux')).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\//); + const prefs = new BrowserStore(join(home, 'browsers.json')); + await prefs.load(); + const registry = new BrowserRegistry({ prefs, send: () => undefined, find: async () => [browser], home }); + expect((await registry.list())[0]).toMatchObject({ id: browser.id, debuggable: true }); + }); + + it('opens the page in a tab of its own there, served the overrides, and leaves your own tab alone', async () => { + await driven.open(browser.id, `${origin}/page.html`, true); + await waitFor(async () => (await driven.read())[0]?.tabs.some((t) => t.title === 'overridden')); + expect(driven.list()).toEqual([{ id: `${browser.id}#everyday`, browserId: browser.id, everyday: true, name: 'Google Chrome · your profile', version: expect.stringMatching(/^\d+\./), tabs: [expect.objectContaining({ url: `${origin}/page.html`, title: 'overridden' })] }]); + // Your own tab isn't listed, attached or served: it still shows the page as the server sent it. + const mine = (await pages()).find((p) => p.url.endsWith('?mine'))!; + expect(mine.attached).toBe(false); + }); + + it('is let go of as it was: the browser still runs, your tab still open', async () => { + driven.stop(`${browser.id}#everyday`); + expect(driven.list()).toEqual([]); + expect((await pages()).some((p) => p.url.endsWith('?mine'))).toBe(true); + expect(await reachableEveryday(browser, home, 'linux')).not.toBeNull(); + }); + + it("isn't found once it has quit, although its profile keeps the address", async () => { + const exited = new Promise((r) => everyday.once('exit', r)); + everyday.kill(); + await exited; + expect(await readActivePort(profile)).not.toBeNull(); + expect(await reachableEveryday(browser, home, 'linux')).toBeNull(); + await expect(driven.open(browser.id, `${origin}/page.html`, true)).rejects.toThrow('Turn on remote debugging in Google Chrome first'); + }); +}); diff --git a/test/unit/browsers.test.ts b/test/unit/browsers.test.ts index e67f577..d58844e 100644 --- a/test/unit/browsers.test.ts +++ b/test/unit/browsers.test.ts @@ -34,6 +34,8 @@ const { driveCommand } = await import('../../src/main/browsers/driven/driveComma const { profileDir } = await import('../../src/main/browsers/driven/profileDir'); const { readActivePort } = await import('../../src/main/browsers/driven/chromium/readActivePort'); const { DrivenTabs } = await import('../../src/main/browsers/driven/DrivenTabs'); +const { everydayProfileDir } = await import('../../src/main/browsers/driven/everyday/everydayProfileDir'); +const { isListening } = await import('../../src/main/browsers/driven/everyday/isListening'); type DrivenTabState = import('../../src/main/browsers/driven/chromium/types').DrivenTabState; const { BrowserStore } = await import('../../src/main/store/BrowserStore'); type FoundBrowser = import('../../src/main/browsers').FoundBrowser; @@ -244,12 +246,12 @@ describe('The registry', () => { await prefs.load(); const events: unknown[] = []; const find = vi.fn(async () => [found]); - return { registry: new BrowserRegistry({ prefs, send: (e) => events.push(e), find }), events, find, prefs }; + return { registry: new BrowserRegistry({ prefs, send: (e) => events.push(e), find, home: tmp }), events, find, prefs }; } it('lists what it found, looks again only after a minute, and announces versions once read', async () => { const { registry: r, events, find } = await registry(); - expect(await r.list()).toEqual([{ id: found.id, name: 'Fake', engine: 'chromium', version: null, icon: null, added: false, hidden: false }]); + expect(await r.list()).toEqual([{ id: found.id, name: 'Fake', engine: 'chromium', version: null, icon: null, added: false, hidden: false, debuggable: false }]); await r.list(); expect(find).toHaveBeenCalledTimes(1); await expect.poll(() => events.length).toBe(1); @@ -267,7 +269,7 @@ describe('The registry', () => { it('adds a program, hides and removes it, and refuses what can’t be run', async () => { const { registry: r, events } = await registry(); const added = await r.add(fake); - expect(added).toEqual({ id: expect.stringMatching(/^added:[0-9a-f]{8}$/), name: 'fake-browser', engine: 'unknown', version: null, icon: null, added: true, hidden: false }); + expect(added).toEqual({ id: expect.stringMatching(/^added:[0-9a-f]{8}$/), name: 'fake-browser', engine: 'unknown', version: null, icon: null, added: true, hidden: false, debuggable: false }); await r.setHidden(added.id, true); expect((await r.list()).find((b) => b.id === added.id)?.hidden).toBe(true); await r.remove(added.id); @@ -343,3 +345,31 @@ describe('Driving a Chromium browser', () => { expect(tabs.clear().map((t) => t.info.id)).toEqual(['b']); }); }); + +describe("Your everyday Chromium browser's profile", () => { + const found = (name: string, command: string[], id = 'desktop:x.desktop'): FoundBrowser => ({ id, name, engine: 'chromium', command, urlAt: command.length, iconFile: null, app: null, program: command[0], added: false }); + + it("is where each system keeps it, by the browser's name or program", () => { + expect(everydayProfileDir(found('Google Chrome', ['/usr/bin/google-chrome-stable']), '/home/me', 'linux')).toBe('/home/me/.config/google-chrome'); + expect(everydayProfileDir(found('Google Chrome (beta)', ['/usr/bin/google-chrome-beta']), '/home/me', 'linux')).toBe('/home/me/.config/google-chrome-beta'); + expect(everydayProfileDir(found('Brave Web Browser', ['/usr/bin/brave-browser']), '/home/me', 'linux')).toBe('/home/me/.config/BraveSoftware/Brave-Browser'); + expect(everydayProfileDir(found('Microsoft Edge', ['open', '-a', '/Applications/Microsoft Edge.app']), '/Users/me', 'darwin')).toBe('/Users/me/Library/Application Support/Microsoft Edge'); + expect(everydayProfileDir(found('Google Chrome', ['C:/Program Files/Google/Chrome/Application/chrome.exe']), 'C:/Users/me', 'win32')).toMatch(/AppData.Local.Google.Chrome.User Data$/); + expect(everydayProfileDir(found('Unknown Browser', ['/usr/bin/unknown']), '/home/me', 'linux')).toBeNull(); + }); + + it("is in a Snap's or a Flatpak's own folder", () => { + expect(everydayProfileDir(found('Chromium', ['/snap/bin/chromium']), '/home/me', 'linux')).toBe('/home/me/snap/chromium/common/chromium'); + expect(everydayProfileDir(found('Google Chrome', ['/usr/bin/flatpak', 'run', '--branch=stable', 'com.google.Chrome']), '/home/me', 'linux')).toBe('/home/me/.var/app/com.google.Chrome/config/google-chrome'); + }); + + it('counts as running when its debugging port takes connections', async () => { + const { createServer } = await import('node:net'); + const server = createServer((socket) => socket.end()); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address() as import('node:net').AddressInfo; + expect(await isListening(port)).toBe(true); + await new Promise((r) => server.close(r)); + expect(await isListening(port)).toBe(false); + }); +}); From 462a9c2fc8c333fdd5e3858af18b75ad54365ecb Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Tue, 29 Sep 2026 09:41:45 +0000 Subject: [PATCH 13/19] List the browser and mode of each driven browser in the driven tests' shapes --- test/integration/driven.chromium.test.ts | 2 +- test/integration/driven.firefox.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/driven.chromium.test.ts b/test/integration/driven.chromium.test.ts index d3e71ff..2406e80 100644 --- a/test/integration/driven.chromium.test.ts +++ b/test/integration/driven.chromium.test.ts @@ -91,7 +91,7 @@ describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes it('launches it with a profile of its own, and serves the overrides in the tab it opens', async () => { await driven.open(browser.id, `${origin}/page.html`); await waitFor(() => tabs().some((t) => t.url === `${origin}/page.html` && t.title === 'overridden')); - expect(driven.list()).toEqual([{ id: browser.id, name: 'Test Chromium', version: expect.stringMatching(/^\d+\./), tabs: expect.any(Array) }]); + expect(driven.list()).toEqual([{ id: browser.id, browserId: browser.id, everyday: false, name: 'Test Chromium', version: expect.stringMatching(/^\d+\./), tabs: expect.any(Array) }]); expect(existsSync(join(profile(), 'DevToolsActivePort'))).toBe(true); expect(events.at(-1)).toMatchObject({ type: 'driven-browsers-changed', driven: [{ id: browser.id }] }); }); diff --git a/test/integration/driven.firefox.test.ts b/test/integration/driven.firefox.test.ts index c24f275..a5468c3 100644 --- a/test/integration/driven.firefox.test.ts +++ b/test/integration/driven.firefox.test.ts @@ -101,7 +101,7 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { it('launches it with a profile of its own, serving the overrides and applying the rules', async () => { await driven.open(browser.id, `${origin}/page.html`); await waitFor(async () => (await tabs()).some((t) => t.title === 'overridden yes blocked from-override')); - expect(driven.list()).toEqual([{ id: browser.id, name: 'Firefox', version: expect.stringMatching(/^\d+\./), tabs: [expect.objectContaining({ url: `${origin}/page.html` })] }]); + expect(driven.list()).toEqual([{ id: browser.id, browserId: browser.id, everyday: false, name: 'Firefox', version: expect.stringMatching(/^\d+\./), tabs: [expect.objectContaining({ url: `${origin}/page.html` })] }]); expect(existsSync(join(profile(), 'WebDriverBiDiServer.json'))).toBe(true); expect(readFileSync(join(profile(), 'user.js'), 'utf8')).toContain('browser.shell.checkDefaultBrowser'); }); From e397ed0a56d9e6fff339570a516958afe59be2a4 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Tue, 29 Sep 2026 09:48:49 +0000 Subject: [PATCH 14/19] Answer a GraphQL operation an override names in Firefox, by the body the page sends --- README.md | 2 +- docs/SPEC.md | 2 +- .../InterceptionEngine/OverrideMatcher.ts | 13 ++++-- src/main/engine/answering/decideRequest.ts | 11 ++--- src/main/engine/bidi/BidiInterception.ts | 23 ++++++++-- src/main/engine/bidi/answerRequest.ts | 6 ++- src/main/engine/bidi/constants.ts | 13 ++++++ src/main/engine/bidi/requestBodyOf.ts | 15 +++++++ src/main/engine/bidi/types.ts | 11 +++-- test/integration/driven.firefox.test.ts | 13 +++++- test/unit/bidi.test.ts | 44 ++++++++++++++++++- 11 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 src/main/engine/bidi/requestBodyOf.ts diff --git a/README.md b/README.md index 7486542..2f30b53 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application S - A response override with **Send request** on (the default for a GET) answers after the server has: the request is still sent, so a POST would still create what it creates, which is why overrides made from anything but a GET start with it off. An event stream an override matches is replaced as a whole, which ends it. - A paused request waits only as long as the page does: if the page gives up on it (a timeout, leaving the page), its tab closes with a note. - WebSocket messages are shown, not changed: Chromium reports them but can't hold or edit them. A HAR import makes overrides for fetch and XHR responses only, not for documents or scripts. -- In Firefox opened with your changes, an override answers before the request is sent (Firefox can only replace a response then): its headers are its file type's rather than the server's, patch mode serves your saved text, an override for one GraphQL operation doesn't apply, and a script behind an `integrity` attribute needs the page's HTML overridden too. Breakpoints, the console and the Network panel stay with the app's own page. Safari is opened as it is. +- In Firefox opened with your changes, an override answers before the request is sent (Firefox can only replace a response then): its headers are its file type's rather than the server's, patch mode serves your saved text, and a script behind an `integrity` attribute needs the page's HTML overridden too. Breakpoints, the console and the Network panel stay with the app's own page. Safari is opened as it is. - A full-page capture stops at 16 384 device pixels tall (Chromium's limit). - Chromium's local-network checks are off in the app's browser, so a patched localhost or intranet page can still reach its own servers. Browse only sites you're working on (see [SPEC §8](docs/SPEC.md#8-security)). diff --git a/docs/SPEC.md b/docs/SPEC.md index d359624..acceebd 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -755,7 +755,7 @@ The page can be opened in the browsers installed on this computer, to check whet - **Launching:** Chromium gets `--user-data-dir`, `--remote-debugging-port=0`, `--no-first-run`, `--no-default-browser-check` and the app's own Local Network Access switches, and writes the port in `DevToolsActivePort`. Firefox gets `--profile`, `--no-remote`, `--new-instance` and `--remote-debugging-port=0`, with a `user.js` that skips its first-run pages, the default-browser question and data reporting, and writes its WebDriver BiDi address in `WebDriverBiDiServer.json`. On macOS a new instance is started (`open -n -a … --args`); for a Flatpak the flags go before its forwarded-file markers. Both start on a blank page. - **Reaching it again:** a browser still open with that profile (the app was restarted, or let go of it) is reached through the address in its profile rather than launched twice: a second launch would hand its address to the running browser and never open a port. - **Chromium** is driven over CDP: a browser-level connection auto-attaches every tab (a new one waits before it loads anything) and gives each one a `PageInterception` of its own, the engine the app's page uses (§6.1–§6.6). Other targets are let go of as they came. -- **Firefox** is driven over WebDriver BiDi (`src/main/engine/bidi/`): one interception for every tab pauses requests while an override or a rule is on, and bypasses the cache then. Before a request is sent, a block rule fails it, a CORS preflight ahead of a request an override answers is allowed, and an override answers it (`src/main/engine/answering/`). Firefox can only replace a body at that point, so the override's headers are its kind's (content type, `no-store`), a response override's status, header changes and delay, readable cross-origin, then the header rules. At the response's head, header and CORS rules change it. What this leaves out in Firefox: patch mode (the saved text is served), redeploy detection, an override naming a GraphQL operation (BiDi gives no request body), an overridden script behind an `integrity` attribute unless the page's HTML is overridden too, and breakpoints. +- **Firefox** is driven over WebDriver BiDi (`src/main/engine/bidi/`): one interception for every tab pauses requests while an override or a rule is on, and bypasses the cache then. Before a request is sent, a block rule fails it, a CORS preflight ahead of a request an override answers is allowed, and an override answers it (`src/main/engine/answering/`). Firefox can only replace a body at that point, so the override's headers are its kind's (content type, `no-store`), a response override's status, header changes and delay, readable cross-origin, then the header rules. At the response's head, header and CORS rules change it. An override naming a GraphQL operation answers by the request's body, which Firefox keeps for the app (a data collector, bodies up to 1 MiB) only while such an override is on; an older Firefox that can't keep them sends those requests on. Its preflight is allowed whatever operation it names. What this leaves out in Firefox: patch mode (the saved text is served), redeploy detection, an overridden script behind an `integrity` attribute unless the page's HTML is overridden too, and breakpoints. - **Keeping them in step:** overrides, rules and settings that change reach the driven tabs at once, and with **Reload page after changes** on, their tabs showing a website reload (after a burst, once). Nothing is reported from them: the console, Network panel and inspector stay the app page's. - **Tabs:** the menu lists each driven browser's tabs, with their titles (read when a page has loaded and whenever the menu opens, since neither protocol announces a title) and addresses. A tab can be brought to the front, loaded in the app, or captured. The × lets go of the browser, which stays open. A browser the user quits is forgotten, and the status bar counts the tabs served your changes. diff --git a/src/main/engine/InterceptionEngine/OverrideMatcher.ts b/src/main/engine/InterceptionEngine/OverrideMatcher.ts index 150b27b..3c2e064 100644 --- a/src/main/engine/InterceptionEngine/OverrideMatcher.ts +++ b/src/main/engine/InterceptionEngine/OverrideMatcher.ts @@ -40,18 +40,23 @@ export class OverrideMatcher { } /** - * The enabled response override that answers `url` for `method` without sending it, whatever GraphQL - * operation it names: what a CORS preflight asking to send `method` is answered for. + * The enabled override that answers `url` for `method`, whatever GraphQL operation it names (among those `answers` + * keeps): what a CORS preflight asking to send `method` is answered for. */ - unsentFor(url: string, resourceType: string, method: string): Override | undefined { + preflightFor(url: string, resourceType: string, method: string, answers: (o: Override) => boolean = () => true): Override | undefined { // A preflight carries no body to name an operation: only the method is asked of the override. const preflight: MatchedRequest = { method, operation: () => undefined }; return this.opts.getOverrides().find((o) => { - if (!o.enabled || sendsRequest(o) || !answersKind(o.kind, resourceType)) return false; + if (!o.enabled || !answers(o) || !answersKind(o.kind, resourceType)) return false; return requestMatches(o.request && { method: o.request.method, operation: '' }, preflight) && this.matcherFor(o)(url); }); } + /** The response override {@link preflightFor} finds among those that answer without sending the request. */ + unsentFor(url: string, resourceType: string, method: string): Override | undefined { + return this.preflightFor(url, resourceType, method, (o) => !sendsRequest(o)); + } + /** The version of the override that would serve `url` now (`id@updatedAt`), or '' for the live file. */ version(url: string, resourceType: string): string { const o = this.find(url, resourceType); diff --git a/src/main/engine/answering/decideRequest.ts b/src/main/engine/answering/decideRequest.ts index 7a46790..58d6109 100644 --- a/src/main/engine/answering/decideRequest.ts +++ b/src/main/engine/answering/decideRequest.ts @@ -1,3 +1,4 @@ +import { matchedRequestOf } from '../InterceptionEngine/matchedRequestOf'; import { overrideBody } from '../InterceptionEngine/overrideBody'; import { applyCors, findBlockRule, isPreflight, preflightMethod, type PausedRequest } from '../rules'; import { PREFLIGHT_STATUS } from '../rules/constants'; @@ -7,18 +8,18 @@ import type { AnswerContext, RequestDecision } from './types'; /** * What to do with a request before it is sent, where a browser can only replace a response then (Firefox, WebKit): - * fail it when a block rule takes it; allow a CORS preflight ahead of a request an override answers; answer a request - * an override takes with it; send anything else on. A request's body isn't known, so an override naming a GraphQL - * operation never answers. + * fail it when a block rule takes it; allow a CORS preflight ahead of a request an override answers (whatever GraphQL + * operation it names); answer a request an override takes with it; send anything else on. An override naming an + * operation answers only a request whose body the browser gave. */ export function decideRequest(ctx: AnswerContext, request: PausedRequest, resourceType: string): RequestDecision { const { sources, overrides, matchers } = ctx; if (findBlockRule(sources.getRules(), request.url, resourceType, matchers)) return { action: 'fail' }; const method = isPreflight(request) ? preflightMethod(request) : undefined; - if (method && overrides.find(request.url, PREFLIGHT_RESOURCE, { method, operation: () => undefined })) { + if (method && overrides.preflightFor(request.url, PREFLIGHT_RESOURCE, method)) { return { action: 'answer', head: applyCors({ status: PREFLIGHT_STATUS, headers: [] }, request), body: '', delayMs: 0 }; } - const override = overrides.find(request.url, resourceType, { method: request.method, operation: () => undefined }); + const override = overrides.find(request.url, resourceType, matchedRequestOf(request)); if (!override) return { action: 'continue' }; return { action: 'answer', head: servedHead(override, request, resourceType, ctx), body: overrideBody(override, sources.getSettings()), delayMs: override.response?.delayMs ?? 0 }; } diff --git a/src/main/engine/bidi/BidiInterception.ts b/src/main/engine/bidi/BidiInterception.ts index a6a5c97..4b441a0 100644 --- a/src/main/engine/bidi/BidiInterception.ts +++ b/src/main/engine/bidi/BidiInterception.ts @@ -1,7 +1,7 @@ import { answerContext, type AnswerSources } from '../answering'; import { answerRequest } from './answerRequest'; import { answerResponse } from './answerResponse'; -import { BIDI, CACHE_BEHAVIOR, INTERCEPT_PHASES } from './constants'; +import { BIDI, CACHE_BEHAVIOR, INTERCEPT_PHASES, MAX_REQUEST_BODY, REQUEST_DATA } from './constants'; import type { BidiConnection } from './BidiConnection'; import type { BidiAnswerContext, BidiNetworkEvent } from './types'; @@ -14,7 +14,8 @@ const ANSWERS: Readonly void) | undefined; constructor(connection: BidiConnection, sources: AnswerSources) { - this.ctx = { ...answerContext(sources), connection }; + this.ctx = { ...answerContext(sources), connection, collector: null }; } async start(): Promise { @@ -51,7 +52,9 @@ export class BidiInterception { stop(): void { this.off?.(); if (this.intercept) this.ctx.connection.send(BIDI.network.removeIntercept, { intercept: this.intercept }).catch(() => undefined); + if (this.ctx.collector) this.ctx.connection.send(BIDI.network.removeDataCollector, { collector: this.ctx.collector }).catch(() => undefined); this.intercept = null; + this.ctx.collector = null; } private async update(): Promise { @@ -65,6 +68,20 @@ export class BidiInterception { this.intercept = null; await connection.send(BIDI.network.removeIntercept, { intercept }).catch(() => undefined); } + await this.collect(sources.getOverrides().some((o) => o.enabled && !!o.request?.operation)); await this.applySettings(); } + + /** Keeps request bodies while `needed` (never in a Firefox too old to keep them: the operation is then unknown). */ + private async collect(needed: boolean): Promise { + const { connection, collector } = this.ctx; + if (needed && !collector) { + const params = { dataTypes: [REQUEST_DATA], maxEncodedDataSize: MAX_REQUEST_BODY }; + this.ctx.collector = (await connection.send<{ collector: string }>(BIDI.network.addDataCollector, params).catch(() => null))?.collector ?? null; + } + if (!needed && collector) { + this.ctx.collector = null; + await connection.send(BIDI.network.removeDataCollector, { collector }).catch(() => undefined); + } + } } diff --git a/src/main/engine/bidi/answerRequest.ts b/src/main/engine/bidi/answerRequest.ts index b8077d7..1ad31de 100644 --- a/src/main/engine/bidi/answerRequest.ts +++ b/src/main/engine/bidi/answerRequest.ts @@ -2,6 +2,7 @@ import { decideRequest, type RequestDecision } from '../answering'; import { holdFor } from '../InterceptionEngine/holdFor'; import { BIDI } from './constants'; import { provideResponse } from './provideResponse'; +import { requestBodyOf } from './requestBodyOf'; import { requestOf } from './requestOf'; import { resourceTypeOf } from './resourceTypeOf'; import type { BidiAnswerContext, BidiNetworkEvent } from './types'; @@ -16,9 +17,10 @@ const CARRY_OUT: { [A in RequestDecision['action']]: (ctx: BidiAnswerContext, re }, }; -/** Answers a request paused before it is sent, as {@link decideRequest} decides. */ +/** Answers a request paused before it is sent, as {@link decideRequest} decides (with its body, when kept). */ export async function answerRequest(ctx: BidiAnswerContext, { request: data }: BidiNetworkEvent): Promise { - const decision = decideRequest(ctx, requestOf(data), resourceTypeOf(data)); + const request = { ...requestOf(data), body: await requestBodyOf(ctx, data) }; + const decision = decideRequest(ctx, request, resourceTypeOf(data)); const carryOut = CARRY_OUT[decision.action] as (ctx: BidiAnswerContext, request: string, decision: RequestDecision) => Promise; await carryOut(ctx, data.request, decision); } diff --git a/src/main/engine/bidi/constants.ts b/src/main/engine/bidi/constants.ts index e94ca6f..59824aa 100644 --- a/src/main/engine/bidi/constants.ts +++ b/src/main/engine/bidi/constants.ts @@ -18,11 +18,14 @@ export const BIDI = { navigationStarted: 'browsingContext.navigationStarted', }, network: { + addDataCollector: 'network.addDataCollector', addIntercept: 'network.addIntercept', continueRequest: 'network.continueRequest', continueResponse: 'network.continueResponse', failRequest: 'network.failRequest', + getData: 'network.getData', provideResponse: 'network.provideResponse', + removeDataCollector: 'network.removeDataCollector', removeIntercept: 'network.removeIntercept', setCacheBehavior: 'network.setCacheBehavior', // Events @@ -35,6 +38,16 @@ export const BIDI = { /** The phases the interception pauses requests at: before they are sent (to block or answer them), and at their response's head. */ export const INTERCEPT_PHASES = ['beforeRequestSent', 'responseStarted'] as const; +/** + * The data a collector keeps (request bodies, read for the GraphQL operation they name), and the largest body it + * keeps (bytes): a GraphQL request is far smaller, and a larger one is sent on unread. + */ +export const REQUEST_DATA = 'request'; +export const MAX_REQUEST_BODY = 1_048_576; + +/** The type of bytes BiDi gives as base64 (binary), not as text. */ +export const BASE64_BYTES = 'base64'; + /** Cache behaviours: the network cache used as usual, or bypassed (so every request reaches the interception). */ export const CACHE_BEHAVIOR = { normal: 'default', bypass: 'bypass' } as const; diff --git a/src/main/engine/bidi/requestBodyOf.ts b/src/main/engine/bidi/requestBodyOf.ts new file mode 100644 index 0000000..07247ac --- /dev/null +++ b/src/main/engine/bidi/requestBodyOf.ts @@ -0,0 +1,15 @@ +import { BASE64_BYTES, BIDI, REQUEST_DATA } from './constants'; +import type { BidiAnswerContext, BidiBytes, BidiRequestData } from './types'; + +/** + * A paused request's body as text, let go of once read: only while a collector keeps bodies (an override names a + * GraphQL operation), and only one it kept (it has a body, not too large). Undefined otherwise. + */ +export async function requestBodyOf({ connection, collector }: BidiAnswerContext, { request, bodySize }: BidiRequestData): Promise { + if (!collector || !bodySize) return undefined; + const params = { dataType: REQUEST_DATA, collector, request, disown: true }; + const data = await connection.send<{ bytes: BidiBytes }>(BIDI.network.getData, params).catch(() => null); + if (!data) return undefined; + const { type, value } = data.bytes; + return type === BASE64_BYTES ? Buffer.from(value, 'base64').toString('utf8') : value; +} diff --git a/src/main/engine/bidi/types.ts b/src/main/engine/bidi/types.ts index f54a62b..186072b 100644 --- a/src/main/engine/bidi/types.ts +++ b/src/main/engine/bidi/types.ts @@ -1,10 +1,13 @@ import type { AnswerContext } from '../answering'; import type { BidiConnection } from './BidiConnection'; +/** Bytes as BiDi carries them: text, or base64. */ +export type BidiBytes = { type: 'string'; value: string } | { type: 'base64'; value: string }; + /** A header as BiDi carries it. */ export interface BidiHeader { name: string; - value: { type: 'string'; value: string } | { type: 'base64'; value: string }; + value: BidiBytes; } /** What `network.beforeRequestSent` and `network.responseStarted` say of a request, as far as interception needs. */ @@ -13,6 +16,8 @@ export interface BidiRequestData { url: string; method: string; headers: BidiHeader[]; + /** The size of its body, 0 without one (null when unknown). */ + bodySize?: number | null; destination?: string; initiatorType?: string | null; } @@ -26,5 +31,5 @@ export interface BidiNetworkEvent { export type BidiEventHandler = (method: string, params: any) => void; -/** What answering a paused request over BiDi works with. */ -export type BidiAnswerContext = AnswerContext & { connection: BidiConnection }; +/** What answering a paused request over BiDi works with: the connection, and the collector keeping bodies, if on. */ +export type BidiAnswerContext = AnswerContext & { connection: BidiConnection; collector: string | null }; diff --git a/test/integration/driven.firefox.test.ts b/test/integration/driven.firefox.test.ts index a5468c3..b5f501a 100644 --- a/test/integration/driven.firefox.test.ts +++ b/test/integration/driven.firefox.test.ts @@ -1,7 +1,7 @@ /** * Firefox driven with the workspace's changes over WebDriver BiDi: launched with a profile of the app's own, a script * override served, a header rule and a block rule applied, a cross-origin request an override answers readable (its - * preflight answered), changes served and the tab reloaded, a tab captured (and at a viewport given), the browser let + * preflight answered), a GraphQL operation answered by the body sent, changes served and the tab reloaded, a tab captured (and at a viewport given), the browser let * go of and reached again, and forgotten once it is quit. Runs where Firefox is found: `FIREFOX_PATH`, or `firefox` on * the PATH. */ @@ -62,10 +62,14 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { beforeAll(async () => { server = createServer((req, res) => { const path = new URL(req.url ?? '/', 'http://x').pathname; + // A GraphQL endpoint on the other origin, readable there but answering no preflight: only the app does. + if (path === '/graphql') return void res.writeHead(req.method === 'OPTIONS' ? 404 : 200, { 'access-control-allow-origin': '*' }).end('upstream'); + const ask = `(name) => fetch('${other}/graphql', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ operationName: name, query: 'query ' + name + ' { a }' }) }).then((r) => r.text(), () => 'refused')`; const script = `Promise.all([fetch('/api').then((r) => r.headers.get('x-rule')), fetch('/blocked.js').then(() => 'loaded', () => 'blocked'), fetch('${other}/data', { method: 'PUT' }).then((r) => r.text(), () => 'refused')]).then((all) => { document.title = [window.appValue, ...all].join(' '); });`; const files: Record = { '/page.html': ['text/html', `upstream`], '/design.html': ['text/html', `-`], + '/graphql.html': ['text/html', `-`], '/app.js': ['text/javascript', "window.appValue = 'upstream';"], '/api': ['application/json', '{}'], '/blocked.js': ['text/javascript', ''], @@ -80,7 +84,7 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { // Another origin (a CORS request), whose server never answers `/data`: only the override does. other = `http://localhost:${port}`; userData = await mkdtemp(join(tmpdir(), 'console-editor-firefox-')); - overrides.push(override(`${origin}/app.js`, 'Script', "window.appValue = 'overridden';"), override(`${other}/data`, 'Fetch', 'from-override', { request: { method: 'PUT', operation: '' }, response: { status: 200, delayMs: 0, headers: [], send: false, patch: false } })); + overrides.push(override(`${origin}/app.js`, 'Script', "window.appValue = 'overridden';"), override(`${other}/data`, 'Fetch', 'from-override', { request: { method: 'PUT', operation: '' }, response: { status: 200, delayMs: 0, headers: [], send: false, patch: false } }), override(`${other}/graphql`, 'Fetch', 'from-graphql', { request: { method: 'POST', operation: 'GetUser' }, response: { status: 200, delayMs: 0, headers: [], send: false, patch: false } })); const rule = { enabled: true, resourceTypes: [], createdAt: now, updatedAt: now }; rules.push({ ...rule, id: 'r1', action: 'headers', match: defaultMatcherFor(`${origin}/api`), headers: [{ operation: 'set', name: 'x-rule', value: 'yes' }] }, { ...rule, id: 'r2', action: 'block', match: defaultMatcherFor(`${origin}/blocked.js`) }); driven = new DrivenBrowsers({ registry: { get: async () => browser }, sources, userData, send: (event) => events.push(event) }); @@ -132,6 +136,11 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { await waitFor(async () => (await tabs()).some((t) => t.url.endsWith('?again') && t.title.startsWith('changed'))); }); + it('answers the GraphQL operation an override names, read off the body the page sends, and its preflight', async () => { + await driven.open(browser.id, `${origin}/graphql.html`); + await waitFor(async () => (await tabs()).some((t) => t.url === `${origin}/graphql.html` && t.title === 'from-graphql upstream')); + }); + it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { const title = async () => (await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? ''; await driven.open(browser.id, `${origin}/design.html`); diff --git a/test/unit/bidi.test.ts b/test/unit/bidi.test.ts index 8485de3..cdcc98f 100644 --- a/test/unit/bidi.test.ts +++ b/test/unit/bidi.test.ts @@ -8,11 +8,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; import { readBidiPort } from '../../src/main/browsers/driven/firefox/readBidiPort'; +import { requestBodyOf } from '../../src/main/engine/bidi/requestBodyOf'; import { requestOf } from '../../src/main/engine/bidi/requestOf'; import { resourceTypeOf } from '../../src/main/engine/bidi/resourceTypeOf'; import { answerContext, decideRequest } from '../../src/main/engine/answering'; import { servedHead } from '../../src/main/engine/answering/servedHead'; -import type { BidiRequestData } from '../../src/main/engine/bidi/types'; +import type { BidiAnswerContext, BidiRequestData } from '../../src/main/engine/bidi/types'; import { defaultMatcherFor } from '../../src/shared/matcher'; import { DEFAULT_SETTINGS, type Override, type Rule } from '../../src/shared/types'; @@ -82,6 +83,47 @@ describe('Deciding what to do with a request before it is sent', () => { expect(decide('https://shop.test/app.js', 'Stylesheet')).toEqual({ action: 'continue' }); expect(decide('https://api.test/data', 'Fetch', { method: 'GET' })).toEqual({ action: 'continue' }); }); + + it('answers a GraphQL operation an override names by the body given, and its preflight whatever the body', () => { + const graphql: Override = { ...api, id: 'o3', sourceUrl: 'https://api.test/graphql', match: defaultMatcherFor('https://api.test/graphql'), content: '{"data":1}', request: { method: 'POST', operation: 'GetUser' } }; + const ctx = answerContext({ getOverrides: () => [graphql], getRules: () => [], getSettings: () => DEFAULT_SETTINGS }); + const post = (body?: string) => decideRequest(ctx, { ...requestOf(data({ url: 'https://api.test/graphql', method: 'POST' })), body }, 'Fetch'); + expect(post(JSON.stringify({ operationName: 'GetUser', query: 'query GetUser { user { id } }' }))).toMatchObject({ action: 'answer', body: '{"data":1}' }); + expect(post(JSON.stringify({ query: 'query GetUser { user { id } }' }))).toMatchObject({ action: 'answer' }); + expect(post(JSON.stringify({ operationName: 'GetCart' }))).toEqual({ action: 'continue' }); + expect(post()).toEqual({ action: 'continue' }); + const preflight = requestOf(data({ url: 'https://api.test/graphql', method: 'OPTIONS', headers: [header('Origin', 'https://app.test'), header('Access-Control-Request-Method', 'POST')] })); + expect(decideRequest(ctx, preflight, 'Other')).toMatchObject({ action: 'answer', head: { status: 204 } }); + }); +}); + +describe("A paused request's body", () => { + const ctx = (collector: string | null, answer: () => Promise) => { + const sent: unknown[] = []; + const send = (method: string, params: unknown) => { + sent.push([method, params]); + return answer(); + }; + return { sent, ctx: { collector, connection: { send } } as unknown as BidiAnswerContext }; + }; + + it('is read as text from the collector keeping it, and let go of there', async () => { + const text = ctx('c1', async () => ({ bytes: { type: 'string', value: '{"query":"{ a }"}' } })); + expect(await requestBodyOf(text.ctx, data({ request: 'r1', bodySize: 17 }))).toBe('{"query":"{ a }"}'); + expect(text.sent).toEqual([['network.getData', { dataType: 'request', collector: 'c1', request: 'r1', disown: true }]]); + const binary = ctx('c1', async () => ({ bytes: { type: 'base64', value: Buffer.from('{"x":1}').toString('base64') } })); + expect(await requestBodyOf(binary.ctx, data({ bodySize: 7 }))).toBe('{"x":1}'); + }); + + it("isn't asked for without a collector or a body, and is unknown when the browser didn't keep it", async () => { + const none = ctx(null, async () => ({})); + expect(await requestBodyOf(none.ctx, data({ bodySize: 10 }))).toBeUndefined(); + const empty = ctx('c1', async () => ({})); + expect(await requestBodyOf(empty.ctx, data({ bodySize: 0 }))).toBeUndefined(); + expect([...none.sent, ...empty.sent]).toEqual([]); + const gone = ctx('c1', () => Promise.reject(new Error('no such network data'))); + expect(await requestBodyOf(gone.ctx, data({ bodySize: 10 }))).toBeUndefined(); + }); }); describe("Firefox's BiDi address", () => { From 687da1a1c7360ca65ba855ec3544101b071de9f1 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Tue, 29 Sep 2026 10:11:18 +0000 Subject: [PATCH 15/19] Capture pages taller than a texture in parts, joined into one image --- docs/BROWSERS_RESEARCH.md | 4 +- docs/SPEC.md | 2 +- .../browsers/driven/firefox/captureContext.ts | 19 ++-- src/main/browsers/driven/firefox/constants.ts | 3 +- src/main/shots/capture/captureInParts.ts | 16 ++++ src/main/shots/capture/captureOverCdp.ts | 21 +++-- src/main/shots/capture/index.ts | 1 + src/main/shots/capture/partHeight.ts | 13 +++ src/main/shots/capture/partsOf.ts | 8 ++ src/main/shots/constants.ts | 15 +++- src/main/shots/png/constants.ts | 21 +++++ src/main/shots/png/index.ts | 1 + src/main/shots/png/plainFirstRow.ts | 24 +++++ src/main/shots/png/pngChunk.ts | 12 +++ src/main/shots/png/readPng.ts | 21 +++++ src/main/shots/png/rowLayoutOf.ts | 11 +++ src/main/shots/png/rowsOf.ts | 20 +++++ src/main/shots/png/stitchPngs.ts | 29 ++++++ src/main/shots/png/types.ts | 21 +++++ test/integration/driven.chromium.test.ts | 11 ++- test/integration/driven.firefox.test.ts | 24 +++-- test/integration/shots.chromium.test.ts | 32 ++++++- test/unit/shots.test.ts | 33 ++++++- test/unit/stitchPngs.test.ts | 90 +++++++++++++++++++ 24 files changed, 418 insertions(+), 34 deletions(-) create mode 100644 src/main/shots/capture/captureInParts.ts create mode 100644 src/main/shots/capture/partHeight.ts create mode 100644 src/main/shots/capture/partsOf.ts create mode 100644 src/main/shots/png/constants.ts create mode 100644 src/main/shots/png/index.ts create mode 100644 src/main/shots/png/plainFirstRow.ts create mode 100644 src/main/shots/png/pngChunk.ts create mode 100644 src/main/shots/png/readPng.ts create mode 100644 src/main/shots/png/rowLayoutOf.ts create mode 100644 src/main/shots/png/rowsOf.ts create mode 100644 src/main/shots/png/stitchPngs.ts create mode 100644 src/main/shots/png/types.ts create mode 100644 test/unit/stitchPngs.test.ts diff --git a/docs/BROWSERS_RESEARCH.md b/docs/BROWSERS_RESEARCH.md index dd87f52..a84689e 100644 --- a/docs/BROWSERS_RESEARCH.md +++ b/docs/BROWSERS_RESEARCH.md @@ -162,7 +162,7 @@ interface Shot { - wait for `document.fonts.ready` and for 500 ms without requests; - hide the design overlay; - optionally, pause animations (`Animation.setPlaybackRate` 0). -- **Limits:** a full page is capped at 16 384 device pixels tall, Chromium's largest texture; stitching tiles comes later. A file is at most 50 MB, and a workspace holds at most 500 shots. +- **Limits:** a full page is capped at 32 767 device pixels tall, the tallest a canvas draws; past Chromium's largest texture (16 384) it is captured in parts and joined (SPEC §6.17). A file is at most 50 MB, and a workspace holds at most 500 shots. - **Importing a design:** **Import design…** (several files), dropping files on the list, or pasting (Figma's Copy as PNG). PNG, JPEG and WebP are accepted. The renderer decodes each one to check and measure it, and the file is kept as it came. - A design's **scale** comes from `@2x` or `@3x` in its name, else 2 when it is wider than 2000 px, else 1. It can be changed on the shot's page. - Importing a Figma frame by its link, through Figma's API with a personal token, comes later. @@ -294,7 +294,7 @@ How it would go, on a machine that can reach Playwright's CDN, with CI installin - [ ] Tabs of everyday browsers: AppleScript on macOS (can't be checked on Linux). - [x] Firefox's session file everywhere: `recovery.jsonlz4` of each profile in `profiles.ini`, read only when asked (SPEC §6.16). - [ ] Figma frames by link (a personal token, kept with `safeStorage`). -- [ ] Stitching full pages taller than the texture limit (a PNG codec in the main process, and hundreds of MB per capture). +- [x] Stitching full pages taller than the texture limit: parts 4096 device pixels tall, their rows packed again as one PNG without decoding it whole (only each part's first row is unfiltered), down to 32 767 device pixels (SPEC §6.17). - [ ] The console, Network and inspector for an outside tab. - [ ] The design over a driven browser's tab (Chromium: the same isolated-world script; Firefox: a preload script in a sandbox, which the probe showed works). diff --git a/docs/SPEC.md b/docs/SPEC.md index acceebd..576de1d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -768,7 +768,7 @@ A workspace keeps **shots**: captures of the page, and designs to check it again **Capturing** (the shots menu's **Capture**: what the page shows, the whole page, an element, in every browser; the palette; a component's page) goes over CDP (`src/main/shots/capture/`): - Waits up to 2 s for the page's view to be on screen (a menu over it shows a still), and lays the design aside while it captures (§6.18). - **What the page shows:** `Page.captureScreenshot`, scrollbars included. -- **The whole page:** the document rendered past the viewport (`captureBeyondViewport`, clipped to its size), which leaves the viewport and scroll as they were, down to Chromium's largest texture (16 384 device pixels tall). +- **The whole page:** the document rendered past the viewport (`captureBeyondViewport`, clipped to its size), which leaves the viewport and scroll as they were, down to 32 767 device pixels, the tallest image a canvas draws (so it can still be compared). Past Chromium's largest texture (16 384 device pixels) a clip comes back cut or blank, so a taller one is captured in parts 4096 device pixels tall (a whole number of device pixels, so they meet on one), one after another, and joined (`src/main/shots/png/`): their rows are packed again as one PNG still filtered, only each part's first row changed to read nothing above it, one part unpacked at a time, off the main thread. A page that tall, at twice the density and full of gradients, takes about 45 s (half of it Chromium drawing the parts) and may come out over the 50 MB a shot can be. An element taller than a texture is captured the same way, and Firefox's page captures too. - **An element:** the inspector's pick (§6.13), its border box (`DOM.getBoxModel`) moved by each cross-site frame's place in its parent (`DOM.getFrameOwner`, asked of the session that has it) and by the scroll, without the inspector's highlight. - A capture's **scale** is its pixels per CSS pixel, and its viewport the window's size in CSS pixels (`innerWidth`, `innerHeight`, read in the page). It is named after its address, viewport width and what it covers (`shop.test-cart-1440-full.png`), and gets a 256 px JPEG thumbnail of its top. - A **driven browser's tab** is captured the same way over its own protocol (brought to the front first, since a hidden tab isn't drawn), with the browser's name and version. diff --git a/src/main/browsers/driven/firefox/captureContext.ts b/src/main/browsers/driven/firefox/captureContext.ts index f11f27e..0193763 100644 --- a/src/main/browsers/driven/firefox/captureContext.ts +++ b/src/main/browsers/driven/firefox/captureContext.ts @@ -1,22 +1,25 @@ -import type { CaptureArea } from '../../../../shared/types'; +import type { CaptureArea, Rect } from '../../../../shared/types'; import { BIDI, type BidiConnection } from '../../../engine/bidi'; -import type { CapturedImage } from '../../../shots/capture'; -import { MAX_TEXTURE_SIDE } from '../../../shots/constants'; +import { captureInParts, type CapturedImage } from '../../../shots/capture'; +import { MAX_PAGE_SIDE } from '../../../shots/constants'; import { readPngSize } from '../../../shots/readPngSize'; -import { METRICS_EXPRESSION, SCREENSHOT_ORIGIN } from './constants'; +import { CLIP_BOX, METRICS_EXPRESSION, SCREENSHOT_ORIGIN } from './constants'; import { evaluateIn } from './evaluateIn'; /** * Captures a Firefox tab as a PNG in device pixels: what its viewport shows, or its whole document (down to the - * height the app captures at most). Its scale is the image's pixels per CSS pixel; its viewport, the window's size. + * height the app captures at most, in parts past a texture's side). Its scale is the image's pixels per CSS pixel; its viewport, the window's size. */ export async function captureContext(connection: BidiConnection, context: string, area: Exclude): Promise { const answer: unknown = JSON.parse((await evaluateIn(connection, context, METRICS_EXPRESSION)) ?? '[]'); const numbers = Array.isArray(answer) && answer.length === 5 && answer.every((n) => typeof n === 'number' && n > 0) ? (answer as number[]) : [0, 0, 1, 0, 0]; const [width, height, ratio, documentWidth, documentHeight] = numbers; - const clip = area === 'page' && documentWidth ? { type: 'box', x: 0, y: 0, width: documentWidth, height: Math.min(documentHeight, Math.floor(MAX_TEXTURE_SIDE / ratio)) } : null; - const { data } = await connection.send<{ data: string }>(BIDI.browsingContext.captureScreenshot, { context, origin: SCREENSHOT_ORIGIN[area], ...(clip ? { clip } : {}) }); - const bytes = Buffer.from(data, 'base64'); + const clip = area === 'page' && documentWidth ? { x: 0, y: 0, width: documentWidth, height: Math.min(documentHeight, Math.floor(MAX_PAGE_SIDE / ratio)) } : null; + const shoot = async (part: Rect | null) => { + const params = { context, origin: SCREENSHOT_ORIGIN[area], ...(part ? { clip: { type: CLIP_BOX, ...part } } : {}) }; + return Buffer.from((await connection.send<{ data: string }>(BIDI.browsingContext.captureScreenshot, params)).data, 'base64'); + }; + const bytes = clip ? await captureInParts(clip, ratio, shoot) : await shoot(null); const size = readPngSize(bytes); const scale = clip ? size.width / clip.width : ratio; return { bytes, ...size, scale, viewport: { width: width || Math.round(size.width / scale), height: height || Math.round(size.height / scale) } }; diff --git a/src/main/browsers/driven/firefox/constants.ts b/src/main/browsers/driven/firefox/constants.ts index 304661a..3743508 100644 --- a/src/main/browsers/driven/firefox/constants.ts +++ b/src/main/browsers/driven/firefox/constants.ts @@ -28,8 +28,9 @@ export const FIREFOX_PREFS: ReadonlyArray = export const TITLE_EXPRESSION = 'document.title'; export const METRICS_EXPRESSION = 'JSON.stringify([innerWidth, innerHeight, devicePixelRatio, document.documentElement.scrollWidth, document.documentElement.scrollHeight])'; -/** What a capture of each area is taken relative to. */ +/** What a capture of each area is taken relative to, and the type of a box clipped out of it. */ export const SCREENSHOT_ORIGIN = { viewport: 'viewport', page: 'document' } as const; +export const CLIP_BOX = 'box'; /** A new top-level context's type. */ export const TAB_TYPE = 'tab'; diff --git a/src/main/shots/capture/captureInParts.ts b/src/main/shots/capture/captureInParts.ts new file mode 100644 index 0000000..4a07cd0 --- /dev/null +++ b/src/main/shots/capture/captureInParts.ts @@ -0,0 +1,16 @@ +import type { Rect } from '../../../shared/types'; +import { MAX_TEXTURE_SIDE, PART_SIDE } from '../constants'; +import { stitchPngs } from '../png'; +import { partHeight } from './partHeight'; +import { partsOf } from './partsOf'; + +/** + * Captures `clip` of a document (CSS pixels) as a PNG with `shoot`, at `ratio` device pixels per CSS pixel: at once + * when it fits in a texture, else in parts, one after another (each lays the page out for its own), joined. + */ +export async function captureInParts(clip: Rect, ratio: number, shoot: (part: Rect) => Promise): Promise { + if (clip.height * ratio <= MAX_TEXTURE_SIDE) return shoot(clip); + const parts: Buffer[] = []; + for (const part of partsOf(clip, partHeight(PART_SIDE, ratio))) parts.push(await shoot(part)); + return stitchPngs(parts); +} diff --git a/src/main/shots/capture/captureOverCdp.ts b/src/main/shots/capture/captureOverCdp.ts index a80db7d..9152feb 100644 --- a/src/main/shots/capture/captureOverCdp.ts +++ b/src/main/shots/capture/captureOverCdp.ts @@ -1,24 +1,27 @@ import { CDP } from '../../engine/constants'; import type { CdpTransport } from '../../engine/cdp'; -import { CAPTURE_FORMAT, MAX_TEXTURE_SIDE } from '../constants'; +import type { Rect } from '../../../shared/types'; +import { CAPTURE_FORMAT, MAX_PAGE_SIDE } from '../constants'; import { readPngSize } from '../readPngSize'; +import { captureInParts } from './captureInParts'; import { clipOf } from './clipOf'; import { windowMetrics } from './windowMetrics'; import type { CapturedImage, CaptureTarget, LayoutMetrics } from './types'; /** * Captures a page over CDP, in device pixels: what its viewport shows (scrollbars included), or a part of its - * document rendered past the viewport (`captureBeyondViewport`), which leaves the viewport and the scroll as they - * were. Its scale is the image's pixels per CSS pixel captured; its viewport, the window's size in CSS pixels. + * document rendered past the viewport (`captureBeyondViewport`, in parts past a texture's side), which leaves the + * viewport and the scroll as they were. Its scale is the image's pixels per CSS pixel captured; its viewport, the + * window's size in CSS pixels. */ export async function captureOverCdp(transport: CdpTransport, target: CaptureTarget): Promise { const [metrics, view] = await Promise.all([transport.send(CDP.Page.getLayoutMetrics), windowMetrics(transport)]); - const clip = clipOf(target, metrics, Math.floor(MAX_TEXTURE_SIDE / view.ratio)); - const { data } = await transport.send<{ data: string }>(CDP.Page.captureScreenshot, { - format: CAPTURE_FORMAT, - ...(clip ? { clip: { ...clip, scale: 1 }, captureBeyondViewport: true } : {}), - }); - const bytes = Buffer.from(data, 'base64'); + const clip = clipOf(target, metrics, Math.floor(MAX_PAGE_SIDE / view.ratio)); + const shoot = async (part: Rect | null) => { + const shown = part ? { clip: { ...part, scale: 1 }, captureBeyondViewport: true } : {}; + return Buffer.from((await transport.send<{ data: string }>(CDP.Page.captureScreenshot, { format: CAPTURE_FORMAT, ...shown })).data, 'base64'); + }; + const bytes = clip ? await captureInParts(clip, view.ratio, shoot) : await shoot(null); const { width, height } = readPngSize(bytes); const scale = clip ? width / clip.width : view.ratio; return { bytes, width, height, scale, viewport: { width: view.width || Math.round(width / scale), height: view.height || Math.round(height / scale) } }; diff --git a/src/main/shots/capture/index.ts b/src/main/shots/capture/index.ts index 1b61cf9..88db1ee 100644 --- a/src/main/shots/capture/index.ts +++ b/src/main/shots/capture/index.ts @@ -1,4 +1,5 @@ export { atViewport } from './atViewport'; export { atWidth } from './atWidth'; +export { captureInParts } from './captureInParts'; export { captureOverCdp } from './captureOverCdp'; export type { BrowserCapture, CapturedImage, CaptureTarget, Viewport } from './types'; diff --git a/src/main/shots/capture/partHeight.ts b/src/main/shots/capture/partHeight.ts new file mode 100644 index 0000000..b21d957 --- /dev/null +++ b/src/main/shots/capture/partHeight.ts @@ -0,0 +1,13 @@ +import { WHOLE_PIXEL_SLACK, WHOLE_PIXEL_TRIES } from '../constants'; + +/** + * How tall a part of a capture is, in CSS pixels, at `ratio` device pixels per CSS pixel: at most `side` device pixels, + * and a whole number of them where a height just below allows, so each part starts on a device pixel. + */ +export function partHeight(side: number, ratio: number): number { + const most = Math.max(1, Math.floor(side / ratio)); + for (let height = most; height > Math.max(0, most - WHOLE_PIXEL_TRIES); height--) { + if (Math.abs(height * ratio - Math.round(height * ratio)) < WHOLE_PIXEL_SLACK) return height; + } + return most; +} diff --git a/src/main/shots/capture/partsOf.ts b/src/main/shots/capture/partsOf.ts new file mode 100644 index 0000000..36429db --- /dev/null +++ b/src/main/shots/capture/partsOf.ts @@ -0,0 +1,8 @@ +import type { Rect } from '../../../shared/types'; + +/** A box cut top to bottom into parts `height` tall, the last one what is left. */ +export function partsOf(box: Rect, height: number): Rect[] { + const parts: Rect[] = []; + for (let y = 0; y < box.height; y += height) parts.push({ ...box, y: box.y + y, height: Math.min(height, box.height - y) }); + return parts; +} diff --git a/src/main/shots/constants.ts b/src/main/shots/constants.ts index 1e0fa3b..7fa8725 100644 --- a/src/main/shots/constants.ts +++ b/src/main/shots/constants.ts @@ -1,10 +1,21 @@ import { APP_BROWSER_ID } from '../../shared/constants'; /** - * Chromium's largest texture side, in device pixels: a capture taller than this comes back cut or blank, so a full - * page is captured down to it. + * Chromium's largest texture side, in device pixels: a capture taller than this comes back cut or blank, so a taller + * one is captured in parts this many device pixels tall (one part unpacked at a time to join them), and joined. */ export const MAX_TEXTURE_SIDE = 16_384; +export const PART_SIDE = 4096; + +/** The tallest full page captured, in device pixels: the tallest image a canvas draws, so it can still be compared. */ +export const MAX_PAGE_SIDE = 32_767; + +/** + * How far a part's height (CSS pixels) may be from a whole number of device pixels, for parts to meet on one, and how + * many heights below the largest are tried for one (enough for the usual ratios: 1.25, 1.5, 4/3, 1.1…). + */ +export const WHOLE_PIXEL_SLACK = 1e-3; +export const WHOLE_PIXEL_TRIES = 16; /** A PNG's width and height, big-endian, after its signature and the IHDR chunk's length and type. */ export const PNG_SIZE_AT = { width: 16, height: 20, minLength: 24 } as const; diff --git a/src/main/shots/png/constants.ts b/src/main/shots/png/constants.ts new file mode 100644 index 0000000..74d4cbc --- /dev/null +++ b/src/main/shots/png/constants.ts @@ -0,0 +1,21 @@ +/** What every PNG starts with. */ +export const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** The chunks a PNG is read and written by: its header, its image data (one zlib stream over all of them), its end. */ +export const CHUNK = { header: 'IHDR', data: 'IDAT', end: 'IEND' } as const; + +/** A chunk's bytes besides its data: its length and type before, its CRC after. */ +export const CHUNK_PARTS = { length: 4, type: 4, crc: 4 } as const; + +/** Where the header's fields are, in its data: width and height (32-bit), bit depth, colour type, interlace method. */ +export const HEADER_AT = { width: 0, height: 4, depth: 8, colorType: 9, interlace: 12 } as const; + +/** Channels per pixel by colour type: grey, RGB, grey and alpha, RGBA (a palette's isn't joined). */ +export const CHANNELS_OF: Readonly> = { 0: 1, 2: 3, 4: 2, 6: 4 }; + +/** The bit depths joined (whole bytes per channel), and the interlace method (none). */ +export const JOINED_DEPTHS: ReadonlySet = new Set([8, 16]); +export const NOT_INTERLACED = 0; + +/** A row's filter type, the byte before it. */ +export const FILTER = { none: 0, sub: 1, up: 2, average: 3, paeth: 4 } as const; diff --git a/src/main/shots/png/index.ts b/src/main/shots/png/index.ts new file mode 100644 index 0000000..3f24e37 --- /dev/null +++ b/src/main/shots/png/index.ts @@ -0,0 +1 @@ +export { stitchPngs } from './stitchPngs'; diff --git a/src/main/shots/png/plainFirstRow.ts b/src/main/shots/png/plainFirstRow.ts new file mode 100644 index 0000000..cfddd05 --- /dev/null +++ b/src/main/shots/png/plainFirstRow.ts @@ -0,0 +1,24 @@ +import { FILTER } from './constants'; + +/** + * How a first row filtered against the row above it (none, as the first of an image) is made to read nothing above + * it: Up then left as it is, with no filter; Average added back to half its left neighbours, with none; Paeth, which + * then predicts from the left neighbour alone, as Sub. + */ +const PLAIN: Readonly void>> = { + [FILTER.up]: (row) => { + row[0] = FILTER.none; + }, + [FILTER.average]: (row, pixelBytes) => { + for (let i = pixelBytes + 1; i < row.length; i++) row[i] = (row[i] + (row[i - pixelBytes] >> 1)) & 0xff; + row[0] = FILTER.none; + }, + [FILTER.paeth]: (row) => { + row[0] = FILTER.sub; + }, +}; + +/** Makes the first row of a part (its filter byte and bytes) independent of the row above, which is the last part's. */ +export function plainFirstRow(row: Buffer, pixelBytes: number): void { + PLAIN[row[0]]?.(row, pixelBytes); +} diff --git a/src/main/shots/png/pngChunk.ts b/src/main/shots/png/pngChunk.ts new file mode 100644 index 0000000..025dd52 --- /dev/null +++ b/src/main/shots/png/pngChunk.ts @@ -0,0 +1,12 @@ +import { crc32 } from 'node:zlib'; +import { CHUNK_PARTS } from './constants'; + +/** A PNG chunk: its length, type, data and the CRC of its type and data. */ +export function pngChunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(CHUNK_PARTS.length + CHUNK_PARTS.type); + head.writeUInt32BE(data.length, 0); + head.write(type, CHUNK_PARTS.length, 'latin1'); + const crc = Buffer.alloc(CHUNK_PARTS.crc); + crc.writeUInt32BE(crc32(data, crc32(head.subarray(CHUNK_PARTS.length))), 0); + return Buffer.concat([head, data, crc]); +} diff --git a/src/main/shots/png/readPng.ts b/src/main/shots/png/readPng.ts new file mode 100644 index 0000000..0b8d8ed --- /dev/null +++ b/src/main/shots/png/readPng.ts @@ -0,0 +1,21 @@ +import { CHUNK, CHUNK_PARTS, PNG_SIGNATURE } from './constants'; +import type { PngParts } from './types'; + +/** A PNG's chunks as joining it takes them: its header's data, the chunks between it and the image data, the image data. */ +export function readPng(bytes: Buffer): PngParts { + if (!bytes.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error("A part of the capture isn't a PNG"); + const parts: PngParts = { header: Buffer.alloc(0), extras: [], data: [] }; + for (let at = PNG_SIGNATURE.length; at < bytes.length; ) { + const length = bytes.readUInt32BE(at); + const start = at + CHUNK_PARTS.length + CHUNK_PARTS.type; + const type = bytes.toString('latin1', at + CHUNK_PARTS.length, start); + const data = bytes.subarray(start, start + length); + if (type === CHUNK.header) parts.header = data; + else if (type === CHUNK.data) parts.data.push(data); + else if (type === CHUNK.end) break; + else if (!parts.data.length) parts.extras.push({ type, data }); + at = start + length + CHUNK_PARTS.crc; + } + if (!parts.header.length || !parts.data.length) throw new Error('A part of the capture came back damaged'); + return parts; +} diff --git a/src/main/shots/png/rowLayoutOf.ts b/src/main/shots/png/rowLayoutOf.ts new file mode 100644 index 0000000..cbebbf9 --- /dev/null +++ b/src/main/shots/png/rowLayoutOf.ts @@ -0,0 +1,11 @@ +import { CHANNELS_OF, HEADER_AT, JOINED_DEPTHS, NOT_INTERLACED } from './constants'; +import type { RowLayout } from './types'; + +/** How the rows of a PNG with this header are laid out; it throws for one that isn't joined (a palette, bits, interlaced). */ +export function rowLayoutOf(header: Buffer): RowLayout { + const channels = CHANNELS_OF[header[HEADER_AT.colorType]]; + const depth = header[HEADER_AT.depth]; + if (!channels || !JOINED_DEPTHS.has(depth) || header[HEADER_AT.interlace] !== NOT_INTERLACED) throw new Error("The capture's parts can't be joined"); + const pixelBytes = (channels * depth) / 8; + return { pixelBytes, rowBytes: header.readUInt32BE(HEADER_AT.width) * pixelBytes + 1 }; +} diff --git a/src/main/shots/png/rowsOf.ts b/src/main/shots/png/rowsOf.ts new file mode 100644 index 0000000..d6b4cbf --- /dev/null +++ b/src/main/shots/png/rowsOf.ts @@ -0,0 +1,20 @@ +import { promisify } from 'node:util'; +import { inflate } from 'node:zlib'; +import { HEADER_AT } from './constants'; +import { plainFirstRow } from './plainFirstRow'; +import type { PngParts, RowLayout } from './types'; + +const unpack = promisify(inflate); + +/** + * Each part's rows, unpacked one part at a time (off the main thread, as packing them again is), its first row made to + * read nothing of the part above. + */ +export async function* rowsOf(parts: readonly PngParts[], { pixelBytes, rowBytes }: RowLayout): AsyncGenerator { + for (const part of parts) { + const rows = await unpack(Buffer.concat(part.data)); + if (rows.length !== part.header.readUInt32BE(HEADER_AT.height) * rowBytes) throw new Error('A part of the capture came back damaged'); + plainFirstRow(rows.subarray(0, rowBytes), pixelBytes); + yield rows; + } +} diff --git a/src/main/shots/png/stitchPngs.ts b/src/main/shots/png/stitchPngs.ts new file mode 100644 index 0000000..96ed80d --- /dev/null +++ b/src/main/shots/png/stitchPngs.ts @@ -0,0 +1,29 @@ +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { createDeflate } from 'node:zlib'; +import { CHUNK, HEADER_AT, PNG_SIGNATURE } from './constants'; +import { pngChunk } from './pngChunk'; +import { readPng } from './readPng'; +import { rowLayoutOf } from './rowLayoutOf'; +import { rowsOf } from './rowsOf'; + +/** + * Joins PNGs of the same width and format, top to bottom, into one: their rows, still filtered, packed again as one + * image (only each part's first row is changed), so no part is decoded whole. The first part's colour space and + * density are kept. + */ +export async function stitchPngs(pngs: readonly Buffer[]): Promise { + const parts = pngs.map(readPng); + const [first] = parts; + const layout = rowLayoutOf(first.header); + const sameFormat = (header: Buffer) => header.readUInt32BE(HEADER_AT.width) === first.header.readUInt32BE(HEADER_AT.width) && header.subarray(HEADER_AT.depth).equals(first.header.subarray(HEADER_AT.depth)); + if (!parts.every((p) => sameFormat(p.header))) throw new Error("The capture's parts can't be joined"); + const packed: Buffer[] = []; + await pipeline(Readable.from(rowsOf(parts, layout)), createDeflate(), async (packing: AsyncIterable) => { + for await (const chunk of packing) packed.push(chunk); + }); + const header = Buffer.from(first.header); + header.writeUInt32BE(parts.reduce((sum, p) => sum + p.header.readUInt32BE(HEADER_AT.height), 0), HEADER_AT.height); + const extras = first.extras.map(({ type, data }) => pngChunk(type, data)); + return Buffer.concat([PNG_SIGNATURE, pngChunk(CHUNK.header, header), ...extras, pngChunk(CHUNK.data, Buffer.concat(packed)), pngChunk(CHUNK.end, Buffer.alloc(0))]); +} diff --git a/src/main/shots/png/types.ts b/src/main/shots/png/types.ts new file mode 100644 index 0000000..76e795c --- /dev/null +++ b/src/main/shots/png/types.ts @@ -0,0 +1,21 @@ +/** A chunk of a PNG: its type, and its data. */ +export interface PngChunk { + type: string; + data: Buffer; +} + +/** What joining a PNG reads of it. */ +export interface PngParts { + /** The header's data (IHDR). */ + header: Buffer; + /** The chunks between the header and the image data (colour space, density…), kept from the first part. */ + extras: PngChunk[]; + /** The image data chunks' data, in order: one zlib stream. */ + data: Buffer[]; +} + +/** How a PNG's rows are laid out: bytes per pixel (at least one), and per row with its filter byte. */ +export interface RowLayout { + pixelBytes: number; + rowBytes: number; +} diff --git a/test/integration/driven.chromium.test.ts b/test/integration/driven.chromium.test.ts index 2406e80..20cac5d 100644 --- a/test/integration/driven.chromium.test.ts +++ b/test/integration/driven.chromium.test.ts @@ -62,7 +62,7 @@ describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes const path = new URL(req.url ?? '/', 'http://x').pathname; const files: Record = { '/page.html': ['text/html', 'upstream'], - '/design.html': ['text/html', `-`], + '/design.html': ['text/html', `-`], '/app.js': ['text/javascript', "document.title = 'upstream';"], }; const file = files[path]; @@ -142,7 +142,10 @@ describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes }); it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { - const title = async () => (await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? ''; + // What the page says of the design (and its width), and which document it is (a reload makes another). + const read = async () => ((await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? '').split(' '); + const title = async () => (await read()).slice(0, -1).join(' '); + const documentId = async () => (await read()).at(-1); await driven.open(browser.id, `${origin}/design.html`); const settings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true } as const; const design = { key: 'd1', base64: encodePng(40, 30, () => [255, 0, 0]).toString('base64'), width: 300, height: 200, settings }; @@ -157,9 +160,9 @@ describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes expect(image.viewport.width).not.toBe(300); await waitFor(async () => (await title()) === 'design 1 300'); // A new document gets it too. + const before = await documentId(); driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); - await waitFor(async () => (await title()) === '-'); - await waitFor(async () => (await title()) === 'design 1 300'); + await waitFor(async () => (await documentId()) !== before && (await title()) === 'design 1 300'); await driven.setDesign(null); await waitFor(async () => /^no design \d+$/.test(await title()) && !(await title()).endsWith(' 300')); }); diff --git a/test/integration/driven.firefox.test.ts b/test/integration/driven.firefox.test.ts index b5f501a..a46fc16 100644 --- a/test/integration/driven.firefox.test.ts +++ b/test/integration/driven.firefox.test.ts @@ -1,7 +1,7 @@ /** * Firefox driven with the workspace's changes over WebDriver BiDi: launched with a profile of the app's own, a script * override served, a header rule and a block rule applied, a cross-origin request an override answers readable (its - * preflight answered), a GraphQL operation answered by the body sent, changes served and the tab reloaded, a tab captured (and at a viewport given), the browser let + * preflight answered), a GraphQL operation answered by the body sent, changes served and the tab reloaded, a tab captured (and at a viewport given, a page taller than a texture in parts), the browser let * go of and reached again, and forgotten once it is quit. Runs where Firefox is found: `FIREFOX_PATH`, or `firefox` on * the PATH. */ @@ -68,8 +68,10 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { const script = `Promise.all([fetch('/api').then((r) => r.headers.get('x-rule')), fetch('/blocked.js').then(() => 'loaded', () => 'blocked'), fetch('${other}/data', { method: 'PUT' }).then((r) => r.text(), () => 'refused')]).then((all) => { document.title = [window.appValue, ...all].join(' '); });`; const files: Record = { '/page.html': ['text/html', `upstream`], - '/design.html': ['text/html', `-`], + '/design.html': ['text/html', `-`], '/graphql.html': ['text/html', `-`], + // 40,000 rows, each its own colour: its position, red the low byte and green the high one. + '/tall.html': ['text/html', ''], '/app.js': ['text/javascript', "window.appValue = 'upstream';"], '/api': ['application/json', '{}'], '/blocked.js': ['text/javascript', ''], @@ -142,7 +144,10 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { }); it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { - const title = async () => (await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? ''; + // What the page says of the design (and its width), and which document it is (a reload makes another). + const read = async () => ((await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? '').split(' '); + const title = async () => (await read()).slice(0, -1).join(' '); + const documentId = async () => (await read()).at(-1); await driven.open(browser.id, `${origin}/design.html`); const settings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true } as const; const design = { key: 'd1', base64: encodePng(40, 30, () => [255, 0, 0]).toString('base64'), width: 300, height: 200, settings }; @@ -157,13 +162,22 @@ describe.skipIf(!firefox)('Firefox driven with your changes', () => { expect(image.viewport.width).not.toBe(300); await waitFor(async () => (await title()) === 'design 1 300'); // A new document gets it too. + const before = await documentId(); driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); - await waitFor(async () => (await title()) === '-'); - await waitFor(async () => (await title()) === 'design 1 300'); + await waitFor(async () => (await documentId()) !== before && (await title()) === 'design 1 300'); await driven.setDesign(null); await waitFor(async () => /^no design \d+$/.test(await title()) && !(await title()).endsWith(' 300')); }); + it('captures a page taller than a texture in parts, joined without a gap or a repeat, down to the tallest a canvas draws', async () => { + const [capture] = await driven.captureAt(`${origin}/tall.html`, { width: 60, height: 600, scale: 1 }); + if ('reason' in capture) throw new Error(capture.reason); + expect(capture.image).toMatchObject({ height: 32_767, scale: 1 }); + const png = decodePng(capture.image.bytes); + const wrong = Array.from({ length: png.height }, (_, y) => y).filter((y) => png.at(1, y)[0] + png.at(1, y)[1] * 256 !== y); + expect(wrong.slice(0, 5)).toEqual([]); + }); + it('forgets it once it is quit', async () => { killMatching(profile()); await waitFor(() => driven.list().length === 0); diff --git a/test/integration/shots.chromium.test.ts b/test/integration/shots.chromium.test.ts index e237ffd..bc1e3b4 100644 --- a/test/integration/shots.chromium.test.ts +++ b/test/integration/shots.chromium.test.ts @@ -1,6 +1,6 @@ /** * Captures against real Chromium: what the viewport shows, the whole page (past the viewport, which stays as it - * was), and an element picked in a cross-site frame (its own process and session), placed through its frame's + * was; in parts, joined, past a texture's side), and an element picked in a cross-site frame (its own process and session), placed through its frame's * owner and captured where it is on the page. */ import { createServer, type Server } from 'node:http'; @@ -102,4 +102,34 @@ describe.skipIf(!chromiumAvailable)('captures in Chromium', () => { const png = decodePng(image.bytes); for (const [x, y] of [[0, 0], [60, 40], [119, 79]]) expect(png.at(x, y)).toEqual([255, 0, 0, 255]); }); + + it('captures a page taller than a texture in parts, joined without a gap or a repeat, down to the tallest a canvas draws', async () => { + const tall = await chrome.newPage(); + try { + await tall.page.setViewportSize({ width: 60, height: 600 }); + // 40,000 rows, each its own colour: its position, red the low byte and green the high one. + await tall.page.setContent(''); + await tall.page.evaluate(() => { + for (let y = 0; y < 40_000; y++) { + const row = document.createElement('div'); + row.style.cssText = `height:1px;background:rgb(${y % 256},${y >> 8},0)`; + document.body.append(row); + } + }); + const rowOf = (pixel: number[]) => pixel[0] + pixel[1] * 256; + for (const scale of [1, 2]) { + await tall.transport.send('Emulation.setDeviceMetricsOverride', { width: 60, height: 600, deviceScaleFactor: scale, mobile: false }); + const width = await tall.page.evaluate(() => document.documentElement.clientWidth); + const image = await captureOverCdp(tall.transport, { area: 'page' }); + const cssHeight = Math.floor(32_767 / scale); + expect(image).toMatchObject({ width: width * scale, height: cssHeight * scale, scale }); + const png = decodePng(image.bytes); + const wrong = Array.from({ length: image.height }, (_, y) => y).filter((y) => rowOf(png.at(1, y)) !== Math.floor(y / scale)); + expect(wrong.slice(0, 5)).toEqual([]); + } + } finally { + await tall.transport.detach(); + await tall.page.close(); + } + }); }); diff --git a/test/unit/shots.test.ts b/test/unit/shots.test.ts index 65067fe..812a507 100644 --- a/test/unit/shots.test.ts +++ b/test/unit/shots.test.ts @@ -8,7 +8,11 @@ import { join } from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; import { ShotStore, type NewShot } from '../../src/main/store/ShotStore'; import { captureName } from '../../src/main/shots/captureName'; +import { encodePng } from '../helpers/encodePng'; +import { captureInParts } from '../../src/main/shots/capture/captureInParts'; import { clipOf } from '../../src/main/shots/capture/clipOf'; +import { partHeight } from '../../src/main/shots/capture/partHeight'; +import { partsOf } from '../../src/main/shots/capture/partsOf'; import { readPngSize } from '../../src/main/shots/readPngSize'; const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'console-editor-shots-'))); @@ -134,7 +138,7 @@ describe('Capturing', () => { expect(captureName('not a url', 800, 'viewport')).toBe('page-800.png'); }); - it('clips to the whole page (down to the texture limit) or to an element moved by the scroll, inside the page', () => { + it('clips to the whole page (down to the height given) or to an element moved by the scroll, inside the page', () => { const metrics = { cssLayoutViewport: { pageX: 0, pageY: 500, clientWidth: 1440, clientHeight: 900 }, cssContentSize: { width: 1440, height: 20_000 } }; expect(clipOf({ area: 'viewport' }, metrics, 8192)).toBeNull(); expect(clipOf({ area: 'page' }, metrics, 8192)).toEqual({ x: 0, y: 0, width: 1440, height: 8192 }); @@ -144,6 +148,33 @@ describe('Capturing', () => { expect(() => clipOf({ area: 'element', box: { x: 10, y: 10, width: 0, height: 10 } }, metrics, 8192)).toThrow('no size'); }); + it('cuts a clip taller than a texture into parts that meet on a device pixel, the last one what is left', () => { + expect(partsOf({ x: 0, y: 100, width: 50, height: 10_000 }, 4096)).toEqual([ + { x: 0, y: 100, width: 50, height: 4096 }, + { x: 0, y: 4196, width: 50, height: 4096 }, + { x: 0, y: 8292, width: 50, height: 1808 }, + ]); + // A whole number of device pixels at each ratio, as near 4096 as that allows. + const ratios = [1, 2, 1.25, 1.5, 1.1, 1.3333333730697632, 3]; + expect(ratios.map((ratio) => partHeight(4096, ratio))).toEqual([4096, 2048, 3276, 2730, 3720, 3069, 1365]); + // None just below: as many CSS pixels as fit. + expect(partHeight(4096, Math.SQRT2)).toBe(2896); + }); + + it('captures a clip at once when it fits in a texture, else part by part', async () => { + const shot: object[] = []; + const shoot = async (part: object) => { + shot.push(part); + return encodePng(2, 1, () => [0, 0, 0]); + }; + await captureInParts({ x: 0, y: 0, width: 2, height: 16_384 }, 1, shoot); + expect(shot).toEqual([{ x: 0, y: 0, width: 2, height: 16_384 }]); + shot.length = 0; + const joined = await captureInParts({ x: 0, y: 0, width: 2, height: 8193 }, 2, shoot); + expect(shot.map((p) => (p as { y: number }).y)).toEqual([0, 2048, 4096, 6144, 8192]); + expect(readPngSize(joined)).toEqual({ width: 2, height: 5 }); + }); + it("reads a PNG's size from its header", () => { expect(readPngSize(pngHeader(1024, 7))).toEqual({ width: 1024, height: 7 }); expect(() => readPngSize(Buffer.alloc(3))).toThrow('empty'); diff --git a/test/unit/stitchPngs.test.ts b/test/unit/stitchPngs.test.ts new file mode 100644 index 0000000..1b1c71a --- /dev/null +++ b/test/unit/stitchPngs.test.ts @@ -0,0 +1,90 @@ +/** + * Joining PNG captures top to bottom (a page taller than a texture, captured in parts): rows filtered in every way the + * format allows, the first row of each part included, come out as the pixels they were; RGB and RGBA; the first + * part's colour space kept; parts that can't be joined refused. + */ +import { deflateSync } from 'node:zlib'; +import { describe, expect, it } from 'vitest'; +import { stitchPngs } from '../../src/main/shots/png'; +import { decodePng } from '../helpers/decodePng'; + +type Color = (x: number, y: number) => number[]; + +function chunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'latin1'); + // The CRC isn't checked when joining: any will do here. + return Buffer.concat([head, data, Buffer.alloc(4)]); +} + +/** Filters a row against the one above it (zeros for the first), as the filter type says. */ +function filterRow(filter: number, row: Buffer, above: Buffer, bpp: number): Buffer { + const out = Buffer.alloc(row.length + 1); + out[0] = filter; + for (let i = 0; i < row.length; i++) { + const [a, b, c] = [i >= bpp ? row[i - bpp] : 0, above[i], i >= bpp ? above[i - bpp] : 0]; + const p = a + b - c; + const paeth = Math.abs(p - a) <= Math.abs(p - b) && Math.abs(p - a) <= Math.abs(p - c) ? a : Math.abs(p - b) <= Math.abs(p - c) ? b : c; + out[i + 1] = (row[i] - [0, a, b, (a + b) >> 1, paeth][filter]) & 0xff; + } + return out; +} + +/** A PNG whose row y is filtered with `filterOf(y)`, and extra chunks before its data. */ +function png(width: number, height: number, channels: 3 | 4, color: Color, filterOf: (y: number) => number, extras: Buffer[] = []): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = channels === 4 ? 6 : 2; + let above = Buffer.alloc(width * channels); + const rows: Buffer[] = []; + for (let y = 0; y < height; y++) { + const row = Buffer.alloc(width * channels); + for (let x = 0; x < width; x++) row.set(color(x, y).slice(0, channels), x * channels); + rows.push(filterRow(filterOf(y), row, above, channels)); + above = row; + } + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', header), ...extras, chunk('IDAT', deflateSync(Buffer.concat(rows))), chunk('IEND', Buffer.alloc(0))]); +} + +// Colours that change along both axes, so every filter has something to predict. +const color: Color = (x, y) => [(x * 37 + y * 11) & 0xff, (x * 5 + y * 71) & 0xff, (x * y + 7) & 0xff, 128 + ((x + y) & 0x7f)]; + +describe('Joining PNG parts', () => { + it('gives the pixels of the parts, top to bottom, whatever filter each row, a first one too, had', async () => { + const width = 9; + // Three parts, each starting on another filter (none, up, average, paeth, sub…), rows cycling through them all. + const parts = [0, 2, 3, 4].map((start, i) => png(width, 5 + i, 3, (x, y) => color(x, y + 100 * i), (y) => (start + y) % 5)); + const joined = decodePng(await stitchPngs(parts)); + expect([joined.width, joined.height]).toEqual([width, 5 + 6 + 7 + 8]); + let top = 0; + for (const [i, rows] of [5, 6, 7, 8].entries()) { + for (let y = 0; y < rows; y++) { + for (let x = 0; x < width; x++) expect(joined.at(x, top + y)).toEqual([...color(x, y + 100 * i).slice(0, 3), 255]); + } + top += rows; + } + }); + + it('joins RGBA parts, keeping the first part\'s colour space and density', async () => { + const srgb = chunk('sRGB', Buffer.from([0])); + const parts = [png(4, 3, 4, color, () => 3, [srgb]), png(4, 2, 4, (x, y) => color(x, y + 3), () => 4)]; + const bytes = await stitchPngs(parts); + const joined = decodePng(bytes); + for (let y = 0; y < 5; y++) for (let x = 0; x < 4; x++) expect(joined.at(x, y)).toEqual(color(x, y)); + expect(bytes.includes(Buffer.from('sRGB'))).toBe(true); + expect(bytes.indexOf('sRGB')).toBeLessThan(bytes.indexOf('IDAT')); + }); + + it("refuses parts that can't be joined: of another width or format, not a PNG, or damaged", async () => { + await expect(stitchPngs([png(4, 2, 3, color, () => 0), png(5, 2, 3, color, () => 0)])).rejects.toThrow("can't be joined"); + await expect(stitchPngs([png(4, 2, 3, color, () => 0), png(4, 2, 4, color, () => 0)])).rejects.toThrow("can't be joined"); + await expect(stitchPngs([Buffer.from('not a png')])).rejects.toThrow("isn't a PNG"); + const cut = png(4, 3, 3, color, () => 0); + const idat = cut.indexOf('IDAT') + 4; + const short = Buffer.concat([cut.subarray(0, idat - 8), chunk('IDAT', deflateSync(Buffer.alloc(5))), chunk('IEND', Buffer.alloc(0))]); + await expect(stitchPngs([cut, short])).rejects.toThrow('came back damaged'); + }); +}); From b6c518e3411493b1ee7c8aa7bfd76d079d60e53b Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Tue, 29 Sep 2026 10:18:01 +0000 Subject: [PATCH 16/19] Tell anti-aliasing apart in the difference between two shots --- docs/BROWSERS_RESEARCH.md | 2 +- docs/SPEC.md | 4 +- .../features/shot/compare/lib/antialiased.ts | 34 +++++++++++++ .../features/shot/compare/lib/brightnessOf.ts | 7 +++ .../features/shot/compare/lib/comparedOf.ts | 8 +++ .../features/shot/compare/lib/constants.ts | 6 +++ .../features/shot/compare/lib/diff.worker.ts | 4 +- .../features/shot/compare/lib/diffPixels.ts | 28 ++++++----- .../shot/compare/lib/hasManySiblings.ts | 16 ++++++ .../src/features/shot/compare/lib/types.ts | 9 +++- .../features/shot/compare/lib/workerTypes.ts | 2 +- .../src/features/shot/compare/model/types.ts | 2 +- .../shot/compare/model/useImageDiff.ts | 2 +- .../editor-panel/ui/ComparePage/DiffStats.tsx | 1 + .../ui/ComparePage/DifferenceView.tsx | 5 +- test/perf/renderer/diff.perf.test.ts | 49 +++++++++++++++++++ test/renderer/shots.test.ts | 19 +++++++ tsconfig.node.json | 3 +- tsconfig.web.json | 3 +- 19 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/features/shot/compare/lib/antialiased.ts create mode 100644 src/renderer/src/features/shot/compare/lib/brightnessOf.ts create mode 100644 src/renderer/src/features/shot/compare/lib/comparedOf.ts create mode 100644 src/renderer/src/features/shot/compare/lib/hasManySiblings.ts create mode 100644 test/perf/renderer/diff.perf.test.ts diff --git a/docs/BROWSERS_RESEARCH.md b/docs/BROWSERS_RESEARCH.md index a84689e..948dc98 100644 --- a/docs/BROWSERS_RESEARCH.md +++ b/docs/BROWSERS_RESEARCH.md @@ -245,7 +245,7 @@ Each phase ends in a pull request of its own. Each updates SPEC, the CHANGELOG a ### Phase 3: Designs and the compare page - [x] **Import design…**, dropping files, pasting: decode and measure in the renderer, guess the scale, keep the file. - [x] The compare page: side by side, swipe, onion skin, difference, and offsets. -- [x] The diff worker: YIQ distance over a threshold, the share of pixels that differ, differing areas (16 px cells, at most 200). Anti-aliased pixels aren't told apart (the threshold stands in), and it wasn't measured on a 1440×10 000 page. +- [x] The diff worker: YIQ distance over a threshold, the share of pixels that differ, differing areas (16 px cells, at most 200), and anti-aliased pixels told apart (shown yellow, not counted). On a 1440×10 000 page with a tenth of its pixels differing it takes about 4 s, 2 s without telling anti-aliasing apart (`test/perf/renderer/diff.perf.test.ts`). - [x] "Compare with the page": capture at the design's width (`Emulation.setDeviceMetricsOverride`), then compare, then clear the emulation. - [x] Tests: unit tests for the diff (identical, shifted and anti-aliased images); e2e: import a PNG, compare it with a capture of a fixture page, check the share of differing pixels. diff --git a/docs/SPEC.md b/docs/SPEC.md index 576de1d..77895e7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -779,7 +779,7 @@ A workspace keeps **shots**: captures of the page, and designs to check it again **The shots menu** (the toolbar's end, in both windows: the latest thumbnails stacked) searches shots by name, address and browser, with All · Captures · Designs. A shot's menu copies it, saves a copy, shows its file, lays it over the page, and deletes it (in the editor). **A shot's page** shows it at fit, 100 % or zoomed in (a pixel grid from 8×), the colour under the pointer, its size and where it came from, and renames it in place. -**Comparing** (a shot's **Compare with…**: another shot, or the page now captured at the shot's width and scale) opens the compare page: side by side (scrolled together), swipe, onion skin, or the difference. The difference is worked out in a worker: both are drawn at their CSS size; pixels whose YIQ distance is over the threshold, or that only one covers, differ. They are shown red over the faded base, with the share of pixels that differ, and the areas that differ (16 px cells joined, at most 200) to step through. The top image can be moved (Shift for 10 px) to line them up. +**Comparing** (a shot's **Compare with…**: another shot, or the page now captured at the shot's width and scale) opens the compare page: side by side (scrolled together), swipe, onion skin, or the difference. The difference is worked out in a worker: both are drawn at their CSS size; pixels whose YIQ distance is over the threshold, or that only one covers, differ. A pair that differs only as anti-aliasing does (Vyšniauskas's detector, as pixelmatch has it: in either image, the pixel has fewer than three neighbours of its own colour, both darker and brighter ones, and the darkest or the brightest of them sits in a flat area of both images) is shown yellow and not counted, since two browsers smooth the same edges differently; on 1440 × 10,000 pixels with a tenth of them differing that takes about 4 s against 2 s without (`test/perf/renderer/diff.perf.test.ts`). The rest are shown red over the faded base, with the share of pixels that differ (and, apart, the share that is only anti-aliasing), and the areas that differ (16 px cells joined, at most 200) to step through. The top image can be moved (Shift for 10 px) to line them up. Images are served to the UI by the `console-editor-shot:` protocol, on the app's own session only (`image/` and `thumb/`); their pixels are read over IPC, since the protocol's images would taint a canvas. @@ -867,7 +867,7 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g |---|---|---| | Unit | Matchers, header/SRI/source-map transforms, stores (persistence, atomic concurrent writes), engine logic and navigation rules with a fake CDP transport, iframe session coordination (timeouts, sessions that go away, cascading detach), worker sessions (setup order and resuming, `Fetch` kept on, settings per worker type, which session lists and credits a worker's files, missed-override reasons, outdated service workers and `prepareReload` (unregistering by scope, letting go, retrying), a service worker's state kept for its next session, shared-worker discovery and holding), minified heuristic, version comparison, CHANGELOG parsing (and that CHANGELOG.md covers `package.json`'s version), the update service with fakes (checks, quiet failures, progress, checksums, install failures, schedule), the Linux desktop entry (quoting paths, which copies install one, a package's entry winning, removing only its own, following a renamed AppImage, writing only what changed) and the `.rpm`'s install script kept in step with electron-builder's, workspaces in the stores (migration, per-workspace tabs, drafts, overrides and actions, deletion, frame names), the action store (each workspace's actions across instances, what it refuses and why, a failed write changing nothing, changes run in order), favicon loading (recognising images, size caps), the console service with a fake CDP transport (frames across sessions, contexts, rows and their previews, format directives, running code, properties, the cap, clearing, the setting), the session observer (handed every session before it runs, never holding interception up) and where the website's and the Actions panel's windows open (its saved place while it is on a screen, another screen than the editor's, fitted to a small one, at the size each opens at) and how that is saved (Keep on top too), rules (validation, the store's persistence, entries it can't read, broken and unreadable files, the engine's stages, header edits, CORS and preflights, request types, patterns), source maps (finding the reference, loading limits, header capture), response overrides that don't send (`unsentOverrides.test.ts`: patterns, the answer and its CORS, the preflight, what goes on to the server, the delay, the setting kept) and breakpoints (`breakpoints.test.ts`: validation and storage, patterns, holding at each stage and each action, what never stops, letting go), the JSON tree (`jsonTree.test.ts`: parsing without loss, offsets, what it refuses, diffing and applying edits, giving up on a document they don't fit), patch mode (`patchMode.test.ts`: the live response patched, edits worked out once per version, the saved text when upstream fails or the edits don't fit, never a stream, the setting kept), HAR files (`har.test.ts`: entries written, sockets' messages, overrides read back and what is left out), the network speed setting (kept, checked) and its commands on every kind of session, the page stack (the React hook stand-in in every session, with `Page` on first, taken out and put back with the setting; a frame looked at once it loads, silently in its main world, once for two loads in quick succession, not after it navigated, and not kept from the document it left; a cross-site iframe's own session, dropped with it; nothing while the console doesn't record, every frame once it does; the console unaffected when the stand-in can't go in; what the page answers checked: unknown ids and signals, builds, versions, at most 40); the component inspector with a fake CDP transport (`componentInspector.test.ts`: every session into inspect mode and out, one that attaches while picking too; what is under the pointer read at most every interval, the latest node winning; a click stopping picking, keeping the node as `$0` and sending its component with each function placed; a node's frame told by its frame owner; the chain's other components; Esc in the page; the last 20 picks kept and older handles freed; highlighting, with the Overlay domain off once picking and highlights are over; a node that can't be read; a production Angular page's view registry found and handed to the adapter, and taken from the page the next time; the element's own listeners, named and placed; what the page says checked: frameworks, builds, state kinds, labels, list caps); scripts' source maps counted per frame (inline, isolated-world, evaluated and other frames' scripts left out); renders, the tree and state (`componentRenders.test.ts`: the binding in every session while recording, one attaching too, removed and its function deleted when it stops; batches sent in order, checked, framed by their context, their functions located once per document; other bindings, bad payloads and anything after stopping ignored; the page's commits checked: kinds, reasons, labels, caps; a tree level read in the frame's main world, a node opened as a pick at its depth and highlighted, a gone frame or bad path refused; a state edit taken only as a known kind, a name and JSON); source maps loaded from files (`sourceMapFileStore.test.ts`: a copy kept per workspace and bundle and read back after a restart, replaced, forgotten, removed with its workspace, its copies deleted; index entries that don't hold up, a copy named outside the folder among them, left out; a file's map handed over as found against the bundle as served); store actions (`storeTimeline.test.ts`: the hooks' one script, which parses; the binding in every session while recording, each frame asked to find its Vue stores, taken out when it stops; batches checked, numbered and framed, others' and late ones ignored, a flood of them capped; known libraries, labels, stacks in loaded files and caps), the events sent to a window as JSON (`appEventWire.test.ts`), the main process's lookups bounded (`inspectorLookups.test.ts`: only the scripts alive kept, whether each names a map, long URLs left out; at most 1,000 functions placed a read, one gone leaving the others; initiators' URLs and names), commits naming their store action and components their own render time, a request's initiator through its async parents in loaded files (`networkLog.test.ts`) | `npm test` | | Renderer | Resource tree building and filtering (worker entries: kept across navigations for service and shared workers, dropped with their worker, filtered by worker URL), command-palette fuzzy matching, missed-override notices by reason (a nested worker's once per override version), update notifications, What's New and page tabs, session sync and workspace switching (pending drafts written, tabs closed without losing them, the other workspace's reopened), console frames (keys, labels, colours), rows, filters, Keep rows, prompt history and frame names, rules (quick rules and Undo, toggling with rollback, rule pages and their forms, applying only what changed, results that arrive after a switch, hits and recent requests), original sources (parsing, positions through pretty-printing, tabs, jumps both ways, the tree, the palette), actions (finding an action's frame by key or by name, labels of frames not on the page, runs and why one couldn't run, the form's openings, saving and deleting, copies, the palette's group, the bridge loading and following them), the Actions window's bridge (what it loads, the events it follows and those it ignores) and the editor's copy of where the panel is and of settings another window changed, the Network panel (rows kept and replaced, earlier loads dropped unless kept, groups and the text filter, status, size and time cells, which actions a request offers) and response overrides (the inferred schema, which override answers a request, opening a response and saving it with its method, operation and answer, the response row's fields and Apply), the bottom pane's tab, held requests (`held.test.ts`: their tabs, what Send does at each stage and with an edited body, fields that can't go out, Send original, Fail, Save as override, closing the tab, the page giving up, never kept in the session), breakpoints (added, toggled, removed, refused, put back when saving fails, Pause like this) and Copy as fetch, quick edits (`quickEdits.test.ts`: lists emptied, texts lengthened but links, ids and dates, the value at the cursor nulled, the rest of the text as typed) and Patch live through the response row's form, the response tree (`responseTree.test.ts`: rows for what is open with unique ids, members removed with their comma, added as the last one is written, the selection after a rename or removal), the page stack (UI libraries named once, the top page's first; findings by kind; one Page stack tab; the bridge's snapshot and `stack-changed`), the component inspector (`componentInspector.test.ts`: a function's place in the bundle mapped to its original, named by the map or read off the original's text in each way code names a function; a React component named after its original, a Vue one by itself; picking toggled as main last said; another component of the chain read, or a toast when the page moved on; the bridge following picking and showing what was picked, in one Component tab renamed in place); hook names read off an original (`hookNames.test.ts`: hook calls laid out as React keeps them, same-file custom hooks followed, an imported one stopping the naming, arrows placed by their parameters); renders, the tree and state (`componentRenders.test.ts`: why a component took part, in words, hooks named; triggers; what a frame's last commit rendered; the log's cap; the bridge following recording and tracing functions once each; tree rows as far as open; a frame's top level, a node opened once and closed, a stale frame's level dropped, a pick revealed down its path; opening a node, and a toast when it can't; setting a value, and a toast when it can't); maps loaded from files (`mapFiles.test.ts`: location keys read back; a bundle's traced places forgotten and traced again once a map is loaded or forgotten, every original and hook name forgotten with the maps and a lookup finishing after dropped, nothing when no file was picked, toasts on failures; the Explorer's menu items); store actions (`storeTimeline.test.ts`: recording followed, actions kept and capped, their calls traced once each, a failure to start said; the app's own call in a stack, past libraries); requests by component (`requestsByComponent.test.ts`: a frame's requests sent through a file, newest first, with the call); a commit's trigger with its action, a set value kept and the action written from it per framework (a hook's place, a class's key, selectors through shadow roots, none without a way in), renders by component (counts, own time summed, reasons, costliest first; kept as commits come and go the same as summed afresh; the log's rows) (`componentRenders.test.ts`) | `npm test` | -| Performance | The inspector on large apps in real Chromium (`test/perf/inspector.perf.test.ts`): a React and a Vue 3 app of some 14,000 components (a 2,000-row table, a chain 1,500 deep in React, 300 in Vue) and stores of 20,000 items (Redux Toolkit, Pinia), as production builds. It times a commit of every row with the hooks off, idle and recording renders, and after picking; hovering and picking deep in the table and at the end of the chain; the page stack; a level of the tree; a batch's size; Redux dispatches and Pinia actions idle, recording and after it stopped. And the built app's UI (`test/perf/ui.perf.test.ts`) fed what large apps record at the pace recording sends it (2,000 commits of 200 components, 1,000 store actions with 50-call stacks, 2,000 requests with 20-call initiators, their places traced through a real bundle's map): how long the window's main thread is blocked, its longest task, the memory the log takes, and how long **By component** takes to show. Each prints its medians and fails past a budget set well above what a laptop measures, so only work growing with the whole page on a hot path trips it; it isn't part of CI | `npm run test:perf` (on a display: `xvfb-run -a` on headless Linux) | +| Performance | The inspector on large apps in real Chromium (`test/perf/inspector.perf.test.ts`): a React and a Vue 3 app of some 14,000 components (a 2,000-row table, a chain 1,500 deep in React, 300 in Vue) and stores of 20,000 items (Redux Toolkit, Pinia), as production builds. It times a commit of every row with the hooks off, idle and recording renders, and after picking; hovering and picking deep in the table and at the end of the chain; the page stack; a level of the tree; a batch's size; Redux dispatches and Pinia actions idle, recording and after it stopped. The compare page's difference on 1440 × 10,000 pixels, anti-aliasing told apart (`test/perf/renderer/diff.perf.test.ts`). And the built app's UI (`test/perf/ui.perf.test.ts`) fed what large apps record at the pace recording sends it (2,000 commits of 200 components, 1,000 store actions with 50-call stacks, 2,000 requests with 20-call initiators, their places traced through a real bundle's map): how long the window's main thread is blocked, its longest task, the memory the log takes, and how long **By component** takes to show. Each prints its medians and fails past a budget set well above what a laptop measures, so only work growing with the whole page on a hot path trips it; it isn't part of CI | `npm run test:perf` (on a display: `xvfb-run -a` on headless Linux) | | Architecture | Feature-Sliced Design layer rules; the code-structure rules (files of at most 150 lines, one function, component, class or store per file named after it, data-only constants/types/index files, no `switch`, no sibling names that differ only in case) | `npm run lint:fsd`, `npm run lint:structure` | | Integration | Engine in real Chromium against the fixture site: gzip, static and runtime SRI, globs, CSS/HTML overrides, 404, redeploy detection, source maps, disable. Iframes through the session-aware WebSocket transport (`test/helpers/chromium.ts`): same-site, cross-site and nested iframes, SRI inside iframes, iframe HTML overrides, same-site navigation, removal, reload; each asserts the iframe really is a separate target. The console on a page of service iframes (same-site, and two on sites of their own): every frame's first log line on its own frame, code run in one frame and another frame's logs reacting, top-level `await` and expanding the result, uncaught errors and rejections, a frame keeping its id across a navigation, a cross-site frame's function refused from the top page but run in the frame itself (why actions run in their frame). Workers against the fixture's `/workers/` page (`workers.chromium.test.ts`, a fresh browser context per test): every kind runs while intercepted; edits reach a dedicated and a module worker's first script and imports, what a nested worker imports (its first script is served in Chromium 141, reported as missed in 152+), a shared worker, a service worker's script and imports, and an audio worklet's module; each file is listed with its worker and read through its session; a shared worker's races are forced by holding back its session's commands; workers under a cross-site iframe are served on its session and removed with it; workers are reported gone; an edit to an installed service worker applies on the next reload, and one whose page was left is reinstalled when its site loads again under other overrides (a workspace switch); the page's `registration.update()` is reported and the next reload undoes it; a service worker is listed again, and not reinstalled, when the page comes back to its site; the cache setting reaches what workers load. Rules (`rules.chromium.test.ts`): blocking before the server sees the request (a script, one inside a cross-site iframe, an iframe's document, a redirect's later hop), a URL both blocked and overridden, the page itself never blocked; a gzipped document's CSP removed and another's added, X-Frame-Options removed so a page can be framed, a document no rule changes never read, Cache-Control and Content-Type on streamed responses, headers back once a rule is off; CORS with credentials, a preflight the API refuses, redirects. Source maps named by headers, `X-SourceMap`, comments and `data:` URIs, a stylesheet's, and an override-served bundle's. The network log and response overrides (`network.chromium.test.ts`, the fixture's `/network/` page): what the log lists for fetch(), GraphQL, a failing call, an event stream and a worker's fetch, page loads counted, request and response bodies read, never a stream's (which keeps streaming); a response override answering fetch() and XHR with its body, status and header changes (the request still reaching the server) and marking the row, its delay, only its method and GraphQL operation, a dedicated worker's fetch, and an event stream left streaming or replaced as a whole. Held and unsent requests (`held.chromium.test.ts`): an override that doesn't send answering another origin's POST and its preflight with the server never hit (and, without it, the server turning the preflight away); breakpoints holding a response (its row marked) until answered edited or as it was, a request before it is sent (its row marked) sent elsewhere or answered, one failed, one the page gave up on let go, everything let go when interception stops, and nothing stopped once off. In `network.chromium.test.ts` too: patch mode keeping the live values it wasn't told to change and answering a failing upstream with the saved text; the page and its worker taken offline and slowed down; a WebSocket's handshake, text and binary messages read incrementally, and its close; the log written as HAR with its bodies and read back as overrides that answer without the server. The page stack (`stack.chromium.test.ts`): a React app the test bundles from the repo's react and react-dom, found through the hook stand-in with its version, as a production and a development build, and by its keys with **Framework hooks** off (and no hook left in the page); what Vue, Pinia, Angular, Next.js and webpack leave in a page; a cross-site iframe's React, which found the stand-in in place before it loaded; no console rows. The component inspector (`inspect.chromium.test.ts`), with real pointer input on carts in React and Vue the test bundles (`test/helpers/bundleApp.ts`), as minified production builds with source maps and as development builds: a production React component's chain, props, state, context and handler, each function traced through the map to its source line; a click the page never gets, and the element as `$0`; the chain's other components, and an element that is gone; a production Vue component through its app's vnode tree, with what the app provides, and a development build's from the element; picking inside a cross-site iframe on its own session; Esc in the page; a useState hook set and rendered, JSON and unsettable values refused; Vue's data and a setup ref set, a computed one refused; the Components tree of a React app through the hook stand-in and by its containers without it, and of a production Vue app (each function placed once however many of its components a level lists), nodes opened as picks at their depth, picks' paths; renders recorded on a React app whose buttons each commit for another reason (own state and a store, a context with a memo child skipped, a parent's state with changed props, a class's state), with triggers, frames, durations and order, nothing after stopping, and a production build's mount recorded from a document loaded while recording, its functions placed through the map. The page stack (`stack.chromium.test.ts`) also counts a frame's scripts that name a source map by comment or header. The other frameworks (`frameworks.chromium.test.ts`), each bundled by the test from the repo's own packages: a production Angular component read through its view registry (found once, then taken from the page without walking the heap again; inputs, a settable signal, a field that isn't, each class placed by the map; hosts named by their tags while hovering), a development build through `window.ng` and its tree, a production build's tree through the registry; a Vue 2 component's props, data (set), what the app provides, chain and tree; a Lit component in its shadow root, its properties and state (set), host chain and tree; a plain page's listeners with names, places and options; a Vue 3 listener's function, not Vue's invoker; store actions (`stores.chromium.test.ts`): Redux Toolkit's (the slice's type, payload, what changed, the handler's line through the map; the page stack names Redux), the React commit after one naming it and nothing once stopped, Zustand's through its devtools, Pinia's actions and direct changes (heard again once an action that never settles stops counting) and Vuex's mutations (found when recording starts and by a document loaded while recording), NgRx's through its StoreDevtools; a request keeping the stack of the handler that sent it; saved actions (`stateActions.chromium.test.ts`): a picked component's action written, the page loaded again, and the action setting the value in React (a hook), Vue 3 production and development (a ref and data), Vue 2 (data), Angular development (a signal; production gets none) and Lit (through two shadow roots). Captures (the viewport, the whole page past it, an element in a cross-site frame, pixel-exact), the design over the page (a strict CSP, difference, after a reload, put back), a Chromium browser driven with your changes (launched, served, reloaded after a change, captured, reached again, forgotten once quit) and Firefox the same way over BiDi, with rules and a CORS preflight (where Firefox is found: `FIREFOX_PATH` or on the PATH) | `npm test` (skips if no Chromium; `npx playwright install chromium`) | | End-to-end | Built Electron app driven by Playwright: on Linux, the editor's title bar in place of the system's (the window buttons clear of its controls, the bar a drag area and its controls not), open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette. The Network panel (`network.e2e.test.ts`): the page's fetch and GraphQL calls listed, Fetch/XHR first and the document under All; a response opened, edited, saved and served to the page, the row marked and the override in the Explorer with its method; another status applied from the response row; the override turned off; requests like the selected one paused and an edited response sent to the page, a paused response saved as an override that answers from then on, a breakpoint written in the menu holding a request before it is sent, and that request failed; the live response patched from the response row, a quick edit saved and served, the page taken offline and back from the network speed menu, a WebSocket's messages in its details, a response edited as a tree with the keyboard (a value retyped, a key renamed, a member removed) and saved, and the requests exported as HAR and imported back as overrides. The page stack (`stack.e2e.test.ts`): the status bar names the fixture's UI libraries and opens the Page stack, which lists each finding by kind with its version, build and evidence, and scans again. The component inspector (`inspect.e2e.test.ts`) on a production React cart framed from another site: the toolbar's button picks and the Inspect view shows what is under the pointer; a click opens the Component page, named `CartItem` and placed at its source line through the map, with props, state, context, the handler by its original name and the chain; **Open original** and **Go to bundle code** land on its definition; the View menu starts and stops picking, and Esc in the editor stops it; the Inspect view's tree lists the cart frame's components down to the one picked, selected; a state value named `qty` (from the original) is set from the Component page and the cart shows it; **Record renders** from the palette records a real click, the Renders tab says it was a click on `button#add-A1` and that `CartItem` rendered for `state qty 5 → 6`, and the Component page lists that render. A map from a file (`mapFile.e2e.test.ts`): a production React bundle that names no map, picked, given its map through **Load a source map…** (the system's dialog stubbed in the main process) and named `CartItem` at its line, then forgotten; a plain page's element shown with its listener and where it is defined; the inspected cart's renders by component, and its set value kept as an action that sets it again after a reload; a Redux Toolkit cart (`dataFlow.e2e.test.ts`): its actions recorded from the palette with what they changed and the line that dispatched them (and their stack), the commit after one naming it, and a request's **Sent by** stack traced to the handler. Other browsers on Linux (a fake launcher found, opened, turned off, added and removed), captures (the shots menu, a capture's page, an element in a cross-site frame, per workspace), designs (imported, compared: 41.9 % in two areas, laid over the page), a real Chromium launcher opened with your changes, its tab captured and the page captured in every browser as a group, the same with Firefox where it is found, and your everyday Firefox's tabs read from a session file | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) | diff --git a/src/renderer/src/features/shot/compare/lib/antialiased.ts b/src/renderer/src/features/shot/compare/lib/antialiased.ts new file mode 100644 index 0000000..5ad7565 --- /dev/null +++ b/src/renderer/src/features/shot/compare/lib/antialiased.ts @@ -0,0 +1,34 @@ +import { brightnessOf } from './brightnessOf'; +import { CHANNELS, FLAT_SIBLINGS } from './constants'; +import { hasManySiblings } from './hasManySiblings'; +import type { ComparedPixels } from './types'; + +/** + * Whether a pixel of `own` that differs from `other` is anti-aliasing, as Vyšniauskas's detector (pixelmatch's) tells: + * fewer than three neighbours of its own colour, some darker and some brighter, and the darkest or the brightest of + * them in a flat area of both images, an edge's two sides. `shift` moves a place in `own` to the same one in `other`. + */ +export function antialiased(own: ComparedPixels, other: ComparedPixels, x: number, y: number, shift: { x: number; y: number }): boolean { + const { data, width, height } = own; + const [left, top, right, bottom] = [Math.max(x - 1, 0), Math.max(y - 1, 0), Math.min(x + 1, width - 1), Math.min(y + 1, height - 1)]; + const center = brightnessOf(data, (y * width + x) * CHANNELS); + let same = x === left || x === right || y === top || y === bottom ? 1 : 0; + // The darkest and the brightest neighbour: how much darker or brighter, and where (as an index into the image). + let [darkest, darkestAt, brightest, brightestAt] = [0, 0, 0, 0]; + for (let ny = top; ny <= bottom; ny++) { + for (let nx = left; nx <= right; nx++) { + if (nx === x && ny === y) continue; + const at = ny * width + nx; + const delta = brightnessOf(data, at * CHANNELS) - center; + if (delta === 0 && ++same >= FLAT_SIBLINGS) return false; + if (delta < darkest) [darkest, darkestAt] = [delta, at]; + if (delta > brightest) [brightest, brightestAt] = [delta, at]; + } + } + if (!darkest || !brightest) return false; + const flat = (at: number) => { + const [fx, fy] = [at % width, Math.floor(at / width)]; + return hasManySiblings(own, fx, fy) && hasManySiblings(other, fx + shift.x, fy + shift.y); + }; + return flat(darkestAt) || flat(brightestAt); +} diff --git a/src/renderer/src/features/shot/compare/lib/brightnessOf.ts b/src/renderer/src/features/shot/compare/lib/brightnessOf.ts new file mode 100644 index 0000000..248736f --- /dev/null +++ b/src/renderer/src/features/shot/compare/lib/brightnessOf.ts @@ -0,0 +1,7 @@ +/** + * How bright a pixel is (YIQ's Y, as {@link yiqDelta} weighs it), blended onto white by its alpha, less white's own + * (the same for every pixel, so differences are unchanged). + */ +export function brightnessOf(data: ArrayLike, i: number): number { + return ((data[i] - 255) * 0.29889531 + (data[i + 1] - 255) * 0.58662247 + (data[i + 2] - 255) * 0.11448223) * (data[i + 3] / 255); +} diff --git a/src/renderer/src/features/shot/compare/lib/comparedOf.ts b/src/renderer/src/features/shot/compare/lib/comparedOf.ts new file mode 100644 index 0000000..b7049b2 --- /dev/null +++ b/src/renderer/src/features/shot/compare/lib/comparedOf.ts @@ -0,0 +1,8 @@ +import { CHANNELS } from './constants'; +import type { ComparedPixels, Pixels } from './types'; + +/** An image's pixels with each one's RGBA also as one number. */ +export function comparedOf(pixels: Pixels): ComparedPixels { + const { buffer, byteOffset, length } = pixels.data; + return { ...pixels, colors: new Uint32Array(buffer, byteOffset, length / CHANNELS) }; +} diff --git a/src/renderer/src/features/shot/compare/lib/constants.ts b/src/renderer/src/features/shot/compare/lib/constants.ts index 000c9a5..470ee96 100644 --- a/src/renderer/src/features/shot/compare/lib/constants.ts +++ b/src/renderer/src/features/shot/compare/lib/constants.ts @@ -16,5 +16,11 @@ export const SAME_FADE = 0.1; /** RGBA of a pixel that differs. */ export const DIFF_COLOR = [255, 0, 64, 255] as const; +/** RGBA of a pixel that differs only as anti-aliasing does (an edge smoothed another way): shown, not counted. */ +export const SMOOTHED_COLOR = [255, 200, 0, 255] as const; + +/** How many neighbours of its own colour make a pixel part of a flat area, and not of an edge (an image's side counts as one). */ +export const FLAT_SIBLINGS = 3; + /** Channels per pixel in RGBA pixel data. */ export const CHANNELS = 4; diff --git a/src/renderer/src/features/shot/compare/lib/diff.worker.ts b/src/renderer/src/features/shot/compare/lib/diff.worker.ts index 3bd082c..9894e84 100644 --- a/src/renderer/src/features/shot/compare/lib/diff.worker.ts +++ b/src/renderer/src/features/shot/compare/lib/diff.worker.ts @@ -7,9 +7,9 @@ self.onmessage = async (event: MessageEvent) => { const { id, a, b, offset, threshold } = event.data; try { const [first, second] = await Promise.all([decodeAt(a), decodeAt(b)]); - const { image, differing, total, regions } = diffPixels(first, second, offset, threshold); + const { image, differing, smoothed, total, regions } = diffPixels(first, second, offset, threshold); const bitmap = await createImageBitmap(new ImageData(image.data as Uint8ClampedArray, image.width, image.height)); - (self as unknown as Worker).postMessage({ id, image: bitmap, differing, total, regions } satisfies DiffResponse, [bitmap]); + (self as unknown as Worker).postMessage({ id, image: bitmap, differing, smoothed, total, regions } satisfies DiffResponse, [bitmap]); } catch (err) { self.postMessage({ id, error: err instanceof Error ? err.message : String(err) } satisfies DiffResponse); } diff --git a/src/renderer/src/features/shot/compare/lib/diffPixels.ts b/src/renderer/src/features/shot/compare/lib/diffPixels.ts index 31806a8..49ed521 100644 --- a/src/renderer/src/features/shot/compare/lib/diffPixels.ts +++ b/src/renderer/src/features/shot/compare/lib/diffPixels.ts @@ -1,4 +1,6 @@ -import { CHANNELS, DIFF_COLOR, MAX_YIQ_DELTA, REGION_CELL, SAME_FADE } from './constants'; +import { antialiased } from './antialiased'; +import { comparedOf } from './comparedOf'; +import { CHANNELS, DIFF_COLOR, MAX_YIQ_DELTA, REGION_CELL, SAME_FADE, SMOOTHED_COLOR } from './constants'; import { regionsOf } from './regionsOf'; import { yiqDelta } from './yiqDelta'; import type { PixelDiff, Pixels } from './types'; @@ -6,34 +8,38 @@ import type { PixelDiff, Pixels } from './types'; /** * Compares two images pixel by pixel, the second moved by `offset`, over the box both cover together. Two pixels * differ when their YIQ distance is over `threshold` (a share of the largest, squared); a pixel only one image covers - * differs too, so a size change shows. + * differs too, so a size change shows. A pair that differs only as anti-aliasing does (in either image) is shown apart, + * and not counted: two browsers smooth the same edges differently. */ export function diffPixels(a: Pixels, b: Pixels, offset: { x: number; y: number }, threshold: number): PixelDiff { const width = Math.max(a.width, b.width + offset.x); const height = Math.max(a.height, b.height + offset.y); + const [first, second] = [comparedOf(a), comparedOf(b)]; + const back = { x: -offset.x, y: -offset.y }; const out = new Uint8ClampedArray(width * height * CHANNELS); const columns = Math.ceil(width / REGION_CELL); const cells = new Uint8Array(columns * Math.ceil(height / REGION_CELL)); const limit = MAX_YIQ_DELTA * threshold * threshold; let differing = 0; + let smoothed = 0; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const o = (y * width + x) * CHANNELS; - const inA = x < a.width && y < a.height; const [bx, by] = [x - offset.x, y - offset.y]; - const inB = bx >= 0 && by >= 0 && bx < b.width && by < b.height; + const inBoth = x < a.width && y < a.height && bx >= 0 && by >= 0 && bx < b.width && by < b.height; const i = (y * a.width + x) * CHANNELS; - const j = (by * b.width + bx) * CHANNELS; - const differs = !inA || !inB || yiqDelta(a.data, i, b.data, j) > limit; - if (differs) { + if (inBoth && yiqDelta(a.data, i, b.data, (by * b.width + bx) * CHANNELS) <= limit) { + out.fill(255 + (a.data[i] * 0.299 + a.data[i + 1] * 0.587 + a.data[i + 2] * 0.114 - 255) * SAME_FADE, o, o + CHANNELS - 1); + out[o + CHANNELS - 1] = 255; + } else if (inBoth && (antialiased(first, second, x, y, back) || antialiased(second, first, bx, by, offset))) { + smoothed++; + out.set(SMOOTHED_COLOR, o); + } else { differing++; cells[Math.floor(y / REGION_CELL) * columns + Math.floor(x / REGION_CELL)] = 1; out.set(DIFF_COLOR, o); - } else { - const gray = 255 + (a.data[i] * 0.299 + a.data[i + 1] * 0.587 + a.data[i + 2] * 0.114 - 255) * SAME_FADE; - out.set([gray, gray, gray, 255], o); } } } - return { image: { data: out, width, height }, differing, total: width * height, regions: regionsOf(cells, columns, Math.ceil(height / REGION_CELL), width, height) }; + return { image: { data: out, width, height }, differing, smoothed, total: width * height, regions: regionsOf(cells, columns, Math.ceil(height / REGION_CELL), width, height) }; } diff --git a/src/renderer/src/features/shot/compare/lib/hasManySiblings.ts b/src/renderer/src/features/shot/compare/lib/hasManySiblings.ts new file mode 100644 index 0000000..4f26157 --- /dev/null +++ b/src/renderer/src/features/shot/compare/lib/hasManySiblings.ts @@ -0,0 +1,16 @@ +import { FLAT_SIBLINGS } from './constants'; +import type { ComparedPixels } from './types'; + +/** Whether a pixel is in a flat area: enough of its neighbours are exactly its colour. False outside the image. */ +export function hasManySiblings({ colors, width, height }: ComparedPixels, x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= width || y >= height) return false; + const [left, top, right, bottom] = [Math.max(x - 1, 0), Math.max(y - 1, 0), Math.min(x + 1, width - 1), Math.min(y + 1, height - 1)]; + const color = colors[y * width + x]; + let same = x === left || x === right || y === top || y === bottom ? 1 : 0; + for (let ny = top; ny <= bottom; ny++) { + for (let nx = left; nx <= right; nx++) { + if ((nx !== x || ny !== y) && colors[ny * width + nx] === color && ++same >= FLAT_SIBLINGS) return true; + } + } + return false; +} diff --git a/src/renderer/src/features/shot/compare/lib/types.ts b/src/renderer/src/features/shot/compare/lib/types.ts index 60497fd..2796cd0 100644 --- a/src/renderer/src/features/shot/compare/lib/types.ts +++ b/src/renderer/src/features/shot/compare/lib/types.ts @@ -7,12 +7,19 @@ export interface Pixels { height: number; } +/** An image's pixels, each also as one number (its RGBA's four bytes), to find the same colour at once. */ +export interface ComparedPixels extends Pixels { + colors: Uint32Array; +} + /** Two images compared: the first at 0,0, the second moved by the offset, over the box both cover together. */ export interface PixelDiff { - /** The difference: differing pixels in red over a faded copy of the first image. */ + /** The difference: differing pixels in red (anti-aliasing in yellow) over a faded copy of the first image. */ image: Pixels; /** How many pixels differ (a pixel only one image covers counts as differing). */ differing: number; + /** How many more differ only as anti-aliasing does: shown in yellow, not counted as differing or boxed. */ + smoothed: number; total: number; /** Boxes around the areas that differ, top to bottom. */ regions: Rect[]; diff --git a/src/renderer/src/features/shot/compare/lib/workerTypes.ts b/src/renderer/src/features/shot/compare/lib/workerTypes.ts index 52957ae..a602802 100644 --- a/src/renderer/src/features/shot/compare/lib/workerTypes.ts +++ b/src/renderer/src/features/shot/compare/lib/workerTypes.ts @@ -15,4 +15,4 @@ export interface DiffRequest { threshold: number; } -export type DiffResponse = { id: number; image: ImageBitmap; differing: number; total: number; regions: Rect[] } | { id: number; error: string }; +export type DiffResponse = { id: number; image: ImageBitmap; differing: number; smoothed: number; total: number; regions: Rect[] } | { id: number; error: string }; diff --git a/src/renderer/src/features/shot/compare/model/types.ts b/src/renderer/src/features/shot/compare/model/types.ts index 4f516ed..68b7620 100644 --- a/src/renderer/src/features/shot/compare/model/types.ts +++ b/src/renderer/src/features/shot/compare/model/types.ts @@ -3,7 +3,7 @@ import type { Rect } from '@common/types'; /** Where comparing two shots is at: not asked, working, its result, or why it failed. */ export type DiffState = | { status: 'idle' | 'working' } - | { status: 'done'; image: ImageBitmap; differing: number; total: number; regions: Rect[] } + | { status: 'done'; image: ImageBitmap; differing: number; smoothed: number; total: number; regions: Rect[] } | { status: 'failed'; error: string }; /** A shot as compared: which file, and the size it is drawn at (CSS pixels). */ diff --git a/src/renderer/src/features/shot/compare/model/useImageDiff.ts b/src/renderer/src/features/shot/compare/model/useImageDiff.ts index 2567152..bae3ddf 100644 --- a/src/renderer/src/features/shot/compare/model/useImageDiff.ts +++ b/src/renderer/src/features/shot/compare/model/useImageDiff.ts @@ -25,7 +25,7 @@ export function useImageDiff(a: DiffSource, b: DiffSource, offset: { x: number; const data = event.data; if ('error' in data) return setState({ status: 'failed', error: data.error }); current = data.image; - setState({ status: 'done', image: data.image, differing: data.differing, total: data.total, regions: data.regions }); + setState({ status: 'done', image: data.image, differing: data.differing, smoothed: data.smoothed, total: data.total, regions: data.regions }); }; const timer = setTimeout(async () => { setState({ status: 'working' }); diff --git a/src/renderer/src/widgets/editor-panel/ui/ComparePage/DiffStats.tsx b/src/renderer/src/widgets/editor-panel/ui/ComparePage/DiffStats.tsx index a18b0ab..aed3913 100644 --- a/src/renderer/src/widgets/editor-panel/ui/ComparePage/DiffStats.tsx +++ b/src/renderer/src/widgets/editor-panel/ui/ComparePage/DiffStats.tsx @@ -25,6 +25,7 @@ const STATS: { [S in DiffState['status']]: (diff: Extract ( <> {differingShare(diff.differing, diff.total)} of pixels differ + {diff.smoothed ? {differingShare(diff.smoothed, diff.total)} more only in anti-aliasing (yellow) : null} {diff.regions.length === 1 ? '1 area' : `${diff.regions.length} areas`} onStep(-1)} /> onStep(1)} /> diff --git a/src/renderer/src/widgets/editor-panel/ui/ComparePage/DifferenceView.tsx b/src/renderer/src/widgets/editor-panel/ui/ComparePage/DifferenceView.tsx index 368445d..1158e82 100644 --- a/src/renderer/src/widgets/editor-panel/ui/ComparePage/DifferenceView.tsx +++ b/src/renderer/src/widgets/editor-panel/ui/ComparePage/DifferenceView.tsx @@ -8,7 +8,10 @@ import type { CompareViewProps } from './types'; /** Room kept above an area scrolled to, so its box shows whole. */ const SCROLL_MARGIN = 24; -/** Where two shots differ: differing pixels in red over the faded base, how many, and each area to step through. */ +/** + * Where two shots differ: differing pixels in red over the faded base (anti-aliasing in yellow), how many, and each + * area to step through. + */ export function DifferenceView({ base, other, zoom, offset }: CompareViewProps) { const diff = useImageDiff(sourceOf(base), sourceOf(other), offset, true); const [at, setAt] = useState(0); diff --git a/test/perf/renderer/diff.perf.test.ts b/test/perf/renderer/diff.perf.test.ts new file mode 100644 index 0000000..2632fdd --- /dev/null +++ b/test/perf/renderer/diff.perf.test.ts @@ -0,0 +1,49 @@ +/** + * How long comparing two tall captures pixel by pixel takes, in the diff worker's function: 1440 × 10,000 pixels of + * "text" (small dark boxes with smoothed edges), its edges smoothed another way in the second image (as another browser + * draws them) and a few boxes really changed, so most of the time goes to telling anti-aliasing apart. It prints the + * median of a few runs, and fails only past a budget well above what a laptop measures. + */ +import { describe, expect, it } from 'vitest'; +import { diffPixels } from '@/features/shot/compare/lib/diffPixels'; + +const WIDTH = 1440; +const HEIGHT = 10_000; +const RUNS = 3; +const BUDGET_MS = 15_000; + +/** The runner's stderr, where the results go (a passing test's console is hidden); this file has no Node types. */ +const { stderr } = (globalThis as unknown as { process: { stderr: { write(text: string): void } } }).process; + +/** A page of "text": 12 × 8 boxes on a 20 px grid, their edges grey (`edge`), every 97th box missing when `changed`. */ +function page(edge: number, changed: boolean) { + const data = new Uint8ClampedArray(WIDTH * HEIGHT * 4).fill(255); + for (let y = 0; y < HEIGHT; y++) { + for (let x = 0; x < WIDTH; x++) { + const [cx, cy] = [x % 20, y % 20]; + if (cx > 13 || cy > 9 || (changed && (Math.floor(x / 20) + Math.floor(y / 20) * 72) % 97 === 0)) continue; + const value = cx === 0 || cx === 13 || cy === 0 || cy === 9 ? edge : 0; + const i = (y * WIDTH + x) * 4; + data[i] = data[i + 1] = data[i + 2] = value; + } + } + return { data, width: WIDTH, height: HEIGHT }; +} + +describe('comparing tall captures', () => { + it('compares 1440 × 10,000 pixels, telling anti-aliasing apart, within its budget', () => { + const [a, b] = [page(110, false), page(170, true)]; + const times: number[] = []; + let result = diffPixels(a, b, { x: 0, y: 0 }, 0.1); + for (let run = 0; run < RUNS; run++) { + const start = performance.now(); + result = diffPixels(a, b, { x: 0, y: 0 }, 0.1); + times.push(performance.now() - start); + } + const median = times.sort((x, y) => x - y)[Math.floor(RUNS / 2)]; + stderr.write(`\n${'diff 1440 × 10,000 (anti-aliasing told apart)'.padEnd(64)} ${`${median.toFixed(0)} ms`.padStart(9)} budget ${BUDGET_MS} ms\n`); + stderr.write(` ${result.differing} differing, ${result.smoothed} anti-aliasing, ${result.regions.length} areas\n`); + expect(result.smoothed).toBeGreaterThan(result.differing); + expect(median).toBeLessThan(BUDGET_MS); + }); +}); diff --git a/test/renderer/shots.test.ts b/test/renderer/shots.test.ts index b0dbd14..bf6eb22 100644 --- a/test/renderer/shots.test.ts +++ b/test/renderer/shots.test.ts @@ -161,6 +161,25 @@ describe('Comparing', () => { expect(taller).toMatchObject({ differing: 50, total: 150, regions: [{ x: 0, y: 0, width: 10, height: 15 }] }); }); + it('tells anti-aliasing apart: an edge smoothed another way shows in yellow, uncounted; a thin line still differs', async () => { + const { diffPixels } = await import('@/features/shot/compare/lib/diffPixels'); + // Black on the left, white on the right, the edge between them smoothed grey: darker in one, lighter in the other. + const picture = (edge: number, line: boolean) => { + const data = new Uint8ClampedArray(20 * 20 * 4); + for (let y = 0; y < 20; y++) { + for (let x = 0; x < 20; x++) { + const value = x < 10 ? 0 : x === 10 ? edge : line && x === 17 ? 0 : 255; + data.set([value, value, value, 255], (y * 20 + x) * 4); + } + } + return { data, width: 20, height: 20 }; + }; + const result = diffPixels(picture(100, false), picture(180, true), { x: 0, y: 0 }, 0.1); + expect(result).toMatchObject({ differing: 20, smoothed: 20, regions: [{ x: 16, y: 0, width: 4, height: 20 }] }); + expect([...result.image.data.slice((5 * 20 + 10) * 4, (5 * 20 + 10) * 4 + 4)]).toEqual([255, 200, 0, 255]); + expect([...result.image.data.slice((5 * 20 + 17) * 4, (5 * 20 + 17) * 4 + 4)]).toEqual([255, 0, 64, 255]); + }); + it('says how much differs as a share a person reads', async () => { const { differingShare } = await import('@/widgets/editor-panel/ui/ComparePage/differingShare'); expect(differingShare(0, 100)).toBe('0%'); diff --git a/tsconfig.node.json b/tsconfig.node.json index 8504b0d..4d867d3 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -35,6 +35,7 @@ "exclude": [ "test/renderer/**/*", "test/fixtures/apps/**/*", - "test/perf/apps/**/*" + "test/perf/apps/**/*", + "test/perf/renderer/**/*" ] } diff --git a/tsconfig.web.json b/tsconfig.web.json index c3f0ede..d151ef7 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -37,6 +37,7 @@ "test/fixtures/sourceMaps.ts", "test/fixtures/esbuildApp.ts", "test/fixtures/apps/**/*", - "test/perf/apps/**/*" + "test/perf/apps/**/*", + "test/perf/renderer/**/*" ] } From d7a6b7bc9e20d05007b90976c742ae97cb5953a2 Mon Sep 17 00:00:00 2001 From: olehwebdev Date: Tue, 29 Sep 2026 10:23:49 +0000 Subject: [PATCH 17/19] List your macOS browsers' open tabs through scripting, beside Firefox's --- README.md | 7 +- build/entitlements.mac.plist | 3 + docs/BROWSERS_RESEARCH.md | 2 +- docs/SPEC.md | 16 ++-- electron-builder.ts | 2 + .../BrowserRegistry/BrowserRegistry.ts | 6 ++ src/main/browsers/BrowserRegistry/infoOf.ts | 3 +- src/main/browsers/everyday/index.ts | 1 + .../browsers/everyday/listEverydayTabs.ts | 30 ++----- src/main/browsers/everyday/listFirefoxTabs.ts | 26 ++++++ src/main/browsers/everyday/listsTabs.ts | 7 ++ .../browsers/everyday/scriptable/constants.ts | 36 ++++++++ .../browsers/everyday/scriptable/index.ts | 3 + .../everyday/scriptable/listScriptableTabs.ts | 20 +++++ .../everyday/scriptable/scriptTabsOf.ts | 34 +++++++ .../everyday/scriptable/scriptableApp.ts | 10 +++ .../everyday/scriptable/tabsScript.ts | 27 ++++++ src/main/ipc/registerBrowserIpc.ts | 2 +- .../open-in-browser/model/loadEverydayTabs.ts | 4 +- .../open-in-browser/ui/EverydaySection.tsx | 16 ++-- src/shared/types/browsers.ts | 11 ++- src/shared/types/browsersApi.ts | 2 +- test/unit/browsers.test.ts | 4 +- test/unit/everydayTabs.test.ts | 89 ++++++++++++++++++- 24 files changed, 309 insertions(+), 52 deletions(-) create mode 100644 src/main/browsers/everyday/listFirefoxTabs.ts create mode 100644 src/main/browsers/everyday/listsTabs.ts create mode 100644 src/main/browsers/everyday/scriptable/constants.ts create mode 100644 src/main/browsers/everyday/scriptable/index.ts create mode 100644 src/main/browsers/everyday/scriptable/listScriptableTabs.ts create mode 100644 src/main/browsers/everyday/scriptable/scriptTabsOf.ts create mode 100644 src/main/browsers/everyday/scriptable/scriptableApp.ts create mode 100644 src/main/browsers/everyday/scriptable/tabsScript.ts diff --git a/README.md b/README.md index 2f30b53..114aaa8 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ Everything stays on your machine: no telemetry, no uploads. Besides the sites yo | The profiles of browsers opened with your changes (their logins stay there) | `browsers/` | | The site's cookies, logins, storage | A persistent browser profile used only by the site view | -Your everyday Firefox's session file is read only when you ask the browser menu for its tabs, and only their addresses and titles are shown. +Your everyday Firefox's session file, and on macOS your other browsers' tabs (through scripting, which macOS asks you to allow), are read only when you ask the browser menu for your open tabs, and only their addresses and titles are shown. The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application Support/Console Editor` on macOS and `%APPDATA%\Console Editor` on Windows. Uninstalling the app keeps it. Running from source uses a separate `Console Editor (dev)` folder next to it, so a dev build never touches your real data. Set `CONSOLE_EDITOR_USER_DATA` to use another folder. @@ -233,7 +233,7 @@ The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application S - A paused request waits only as long as the page does: if the page gives up on it (a timeout, leaving the page), its tab closes with a note. - WebSocket messages are shown, not changed: Chromium reports them but can't hold or edit them. A HAR import makes overrides for fetch and XHR responses only, not for documents or scripts. - In Firefox opened with your changes, an override answers before the request is sent (Firefox can only replace a response then): its headers are its file type's rather than the server's, patch mode serves your saved text, and a script behind an `integrity` attribute needs the page's HTML overridden too. Breakpoints, the console and the Network panel stay with the app's own page. Safari is opened as it is. -- A full-page capture stops at 16 384 device pixels tall (Chromium's limit). +- A full-page capture stops at 32 767 device pixels tall, the tallest image the compare page can draw. Past 16 384 (Chromium's limit for one capture) it is captured in parts and joined, which takes a while on a long page at high density. - Chromium's local-network checks are off in the app's browser, so a patched localhost or intranet page can still reach its own servers. Browse only sites you're working on (see [SPEC §8](docs/SPEC.md#8-security)). ## Roadmap @@ -242,8 +242,7 @@ What shipped, release by release, is in the **[CHANGELOG](CHANGELOG.md)**. Next: - [ ] Parameters and scenarios for actions - [ ] Search across every file the page loaded -- [ ] Drive your everyday Chrome over CDP (a Chromium browser with a profile of the app's own already works) -- [ ] WebKit with your changes through Playwright's build, the design over another browser's tab, and tabs of your other everyday browsers ([research](docs/BROWSERS_RESEARCH.md)) +- [ ] WebKit with your changes through Playwright's build ([research](docs/BROWSERS_RESEARCH.md)) - [ ] Vue's renders and a data-flow view in the component inspector ([research](docs/INSPECTOR_RESEARCH.md)) - [ ] Signed and notarized builds (and with them, installing updates in place on macOS) diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 59ee0af..4c977e7 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -17,5 +17,8 @@ com.apple.security.personal-information.location + + com.apple.security.automation.apple-events + diff --git a/docs/BROWSERS_RESEARCH.md b/docs/BROWSERS_RESEARCH.md index 948dc98..dae3741 100644 --- a/docs/BROWSERS_RESEARCH.md +++ b/docs/BROWSERS_RESEARCH.md @@ -291,7 +291,7 @@ How it would go, on a machine that can reach Playwright's CDN, with CI installin - [ ] Tests: skipped where the build isn't there. ### Phase 9: Later -- [ ] Tabs of everyday browsers: AppleScript on macOS (can't be checked on Linux). +- [x] Tabs of everyday browsers on macOS: JavaScript for Automation rather than AppleScript's text (JSON back, each app's terms looked up when run), for Safari, Chrome, Edge, Brave, Arc and Vivaldi, only while they run (SPEC §6.16). The script is checked against a stand-in for JXA's objects; it couldn't be run on a Mac here. - [x] Firefox's session file everywhere: `recovery.jsonlz4` of each profile in `profiles.ini`, read only when asked (SPEC §6.16). - [ ] Figma frames by link (a personal token, kept with `safeStorage`). - [x] Stitching full pages taller than the texture limit: parts 4096 device pixels tall, their rows packed again as one PNG without decoding it whole (only each part's first row is unfiltered), down to 32 767 device pixels (SPEC §6.17). diff --git a/docs/SPEC.md b/docs/SPEC.md index 77895e7..7a943a7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -56,7 +56,7 @@ A desktop app where you enter a website's URL, see every script, stylesheet and | U36 | I capture the page (the viewport, the whole page or one element), in the app or in another browser, and find my captures in a list at the end of the toolbar | ✅ (§6.17) | | U37 | I import a design and check the page against it pixel by pixel: laid over the live page, or side by side, swiped, faded or as a difference | ✅ (§6.17, §6.18) | | U38 | I capture the page in every browser at once and see where they differ from each other or from the design | ✅ (§6.17) | -| U39 | I bring an address from my everyday Firefox into the app: its open tabs are listed in the browser menu | ✅ (§6.16) | +| U39 | I bring an address from my everyday Firefox (or, on macOS, Safari, Chrome, Edge, Brave, Arc or Vivaldi) into the app: its open tabs are listed in the browser menu | ✅ (§6.16) | ## 3. Architecture @@ -153,8 +153,8 @@ src/ findBrowsers/ (Linux desktop entries and icon themes, macOS apps, the Windows registry), startBrowser.ts; driven/ (browsers driven with the workspace's changes: DrivenBrowsers, the Driver interface and DrivenBase, DrivenTabs, launching with a profile and reaching one again; - chromium/ over CDP, firefox/ over WebDriver BiDi); everyday/ (your everyday Firefox's tabs, from - its session file) + chromium/ over CDP, firefox/ over WebDriver BiDi); everyday/ (your everyday browsers' tabs: Firefox's + session file, macOS scripting) shots/ PageShots/ (captures of the app's page, designs, groups), capture/ (over CDP: an area, at a width or a viewport), captureInEveryBrowser.ts, image type and size, thumbnails, the `console-editor-shot:` protocol (§6.17) @@ -759,7 +759,11 @@ The page can be opened in the browsers installed on this computer, to check whet - **Keeping them in step:** overrides, rules and settings that change reach the driven tabs at once, and with **Reload page after changes** on, their tabs showing a website reload (after a burst, once). Nothing is reported from them: the console, Network panel and inspector stay the app page's. - **Tabs:** the menu lists each driven browser's tabs, with their titles (read when a page has loaded and whenever the menu opens, since neither protocol announces a title) and addresses. A tab can be brought to the front, loaded in the app, or captured. The × lets go of the browser, which stays open. A browser the user quits is forgotten, and the status bar counts the tabs served your changes. -**Your everyday Firefox's tabs** (`src/main/browsers/everyday/`): with Firefox installed, the menu offers **Your Firefox tabs**. Once asked, and then each time the menu opens in that run, it reads every Firefox profile's session file (`sessionstore-backups/recovery.jsonlz4`, which Firefox keeps up to date as it runs): `profiles.ini` in Firefox's folder on each system (Snap and Flatpak ones too) names the profiles, the install's default first. The file is an LZ4 block behind a `mozLz40` header, decoded by the app's own reader. Each tab's current page is listed when it is on the web, and choosing one loads it here. +**Your everyday browsers' tabs** (`src/main/browsers/everyday/`): with a browser installed whose tabs can be listed (`listsTabs`: Firefox; on macOS also Safari, Chrome, Edge, Brave, Arc and Vivaldi, with their betas), the menu offers **Your open tabs**. Once asked, and then each time the menu opens in that run, it reads them: +- **Firefox**, on every system: every profile's session file (`sessionstore-backups/recovery.jsonlz4`, which Firefox keeps up to date as it runs): `profiles.ini` in Firefox's folder on each system (Snap and Flatpak ones too) names the profiles, the install's default first. The file is an LZ4 block behind a `mozLz40` header, decoded by the app's own reader. +- **On macOS**, the installed browsers scripting reaches, by their app's name, in one JavaScript for Automation script (`osascript -l JavaScript`, 60 s at most): an app that isn't running is left alone (never started); a running one's windows give their tabs' addresses and titles in two requests each (a window without tabs, as Safari's settings, is skipped), as JSON. The first time, macOS asks whether the app may control that browser (`NSAppleEventsUsageDescription`, and the `automation.apple-events` entitlement); refused, the browser is listed with how to allow it (System Settings › Privacy & Security › Automation). Their tabs aren't one profile's, so they are listed under the browser's name alone. Checked against a stand-in for JXA's objects in the unit tests: there is no Mac to run it on here. + +Each tab's current page is listed when it is on the web, and choosing one loads it here. ### 6.17 Captures and designs @@ -855,7 +859,7 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g - Rules change only what the app's own browser sees. A CORS rule makes an API readable by the page shown, credentials included, which is what it is for; it can't change what the server allows, and cookies still follow the browser's rules. - Other browsers are started as the user's own programs, found where the system lists them or picked by the user, with an http(s) address only. A browser driven with your changes has a profile of the app's own (never the everyday one) and a debugging port on `127.0.0.1` that the system picks; like any debugging port, a local program can connect to it while the browser runs. The app reaches it only through the address that browser wrote in that profile, and lets go of it (the browser stays open) when asked or when the app quits. - A driven browser's tabs are served the workspace's overrides and rules, as the app's page is; nothing is read back from them but their titles, addresses and captures. -- Your everyday Firefox's session file (it holds your open tabs, and more) is read only after you ask for its tabs in the browser menu, in the main process; only each tab's current http(s) address and title reach the UI, and nothing of it is kept or sent anywhere. +- Your everyday Firefox's session file (it holds your open tabs, and more), and on macOS your other browsers' tabs through scripting, are read only after you ask for them in the browser menu, in the main process; only each tab's current http(s) address and title reach the UI, and nothing of it is kept or sent anywhere. - Shots are images the app took or the user imported (checked by their bytes), served to its own UI by the `console-editor-shot:` protocol on its own session only; the site's session can't load them. The design laid over the page lives in an isolated world the page's scripts can't reach, and takes no pointer events; the page can still see the element in its DOM, and it is put back if removed. - The site sees a standard Chrome user agent (Electron tokens removed). - All data stays local: nothing is uploaded, and there is no telemetry. Besides the page and out-of-page fetches for the files you open and for the page's favicon (from the site shown, through its session; kept only if its bytes are an image, and shown as an `` data URL, where SVG can't run scripts), the source maps of the page's scripts and stylesheets when you ask for them (§6.8: http(s) through the site's session, with cookies only for the bundle's or the page's origin; `data:` maps decoded locally; any other scheme refused; 64 MB, 30 s), the only other network calls are the update check (GitHub's releases API and the release's CHANGELOG.md at its tag) and, when you ask for it, the update's download. **Settings › Check for updates** turns the automatic check off. @@ -939,7 +943,7 @@ The package manager is asked rather than electron-builder's `resources/package-t - ✅ A design laid over the live page (a canvas in an isolated world, so a strict CSP doesn't block it), at the design's width (§6.18). - ✅ Chromium browsers with your overrides and rules (M3's external Chrome), and their tabs in the menu (§6.16). - ✅ One capture in every browser, compared in a grid against a baseline (§6.17). -- ✅ Firefox with your changes over WebDriver BiDi (§6.16), and your everyday Firefox's tabs in the menu. +- ✅ Firefox with your changes over WebDriver BiDi (§6.16), and your everyday Firefox's tabs in the menu (on macOS, Safari's, Chrome's and others' too). - Still to come ([research](BROWSERS_RESEARCH.md), phases 8 and 9): WebKit through Playwright's build (it needs `playwright-core` in the packaged app, and a machine that can download the build to check it); the design over a driven browser's tab; tabs of other everyday browsers (AppleScript on macOS); Figma frames by link; full pages taller than 16 384 device pixels; the console, Network and inspector for an outside tab. ## 12. Risks and open questions diff --git a/electron-builder.ts b/electron-builder.ts index 785146d..81ef0f6 100644 --- a/electron-builder.ts +++ b/electron-builder.ts @@ -59,6 +59,8 @@ const config: Configuration = { NSLocationUsageDescription: 'A website open in Console Editor wants to know your location.', NSLocationWhenInUseUsageDescription: 'A website open in Console Editor wants to know your location.', NSLocalNetworkUsageDescription: 'Console Editor opens websites on your local network when you ask it to.', + // Asked when you list your other browsers' tabs; without it macOS refuses without asking. + NSAppleEventsUsageDescription: 'Console Editor lists the tabs open in your browsers when you ask it to, to open one here.', }, }, dmg: { writeUpdateInfo: false }, diff --git a/src/main/browsers/BrowserRegistry/BrowserRegistry.ts b/src/main/browsers/BrowserRegistry/BrowserRegistry.ts index 31bff9d..8e400af 100644 --- a/src/main/browsers/BrowserRegistry/BrowserRegistry.ts +++ b/src/main/browsers/BrowserRegistry/BrowserRegistry.ts @@ -42,6 +42,12 @@ export class BrowserRegistry { return this.infos(); } + /** The browsers found and added, looked for again first when the last scan is old. */ + async installed(): Promise { + await this.scan(); + return this.all(); + } + /** A browser by id, looked for again first when the last scan is old. */ async get(id: string): Promise { await this.scan(); diff --git a/src/main/browsers/BrowserRegistry/infoOf.ts b/src/main/browsers/BrowserRegistry/infoOf.ts index eed5942..da5e8c1 100644 --- a/src/main/browsers/BrowserRegistry/infoOf.ts +++ b/src/main/browsers/BrowserRegistry/infoOf.ts @@ -1,7 +1,8 @@ import type { BrowserInfo } from '../../../shared/types'; +import { listsTabs } from '../everyday'; import type { FoundBrowser } from '../types'; /** What the UI is told of a browser: never its command or paths. */ export function infoOf(browser: FoundBrowser, icon: string | null | undefined, version: string | null | undefined, hidden: ReadonlySet, debuggable: ReadonlySet): BrowserInfo { - return { id: browser.id, name: browser.name, engine: browser.engine, version: version ?? null, icon: icon ?? null, added: browser.added, hidden: hidden.has(browser.id), debuggable: debuggable.has(browser.id) }; + return { id: browser.id, name: browser.name, engine: browser.engine, version: version ?? null, icon: icon ?? null, added: browser.added, hidden: hidden.has(browser.id), debuggable: debuggable.has(browser.id), listsTabs: listsTabs(browser) }; } diff --git a/src/main/browsers/everyday/index.ts b/src/main/browsers/everyday/index.ts index 2b1f931..d0b9215 100644 --- a/src/main/browsers/everyday/index.ts +++ b/src/main/browsers/everyday/index.ts @@ -1 +1,2 @@ export { listEverydayTabs } from './listEverydayTabs'; +export { listsTabs } from './listsTabs'; diff --git a/src/main/browsers/everyday/listEverydayTabs.ts b/src/main/browsers/everyday/listEverydayTabs.ts index 1c790dc..ace1f5d 100644 --- a/src/main/browsers/everyday/listEverydayTabs.ts +++ b/src/main/browsers/everyday/listEverydayTabs.ts @@ -1,27 +1,15 @@ -import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { join } from 'node:path'; import type { EverydayBrowser } from '../../../shared/types'; -import { FIREFOX_DATA_DIRS, PROFILES_INI, SESSION_FILE } from './constants'; -import { profilesOf } from './profilesOf'; -import { readMozLz4 } from './readMozLz4'; -import { sessionTabsOf } from './sessionTabsOf'; - -/** The browser name everyday Firefox tabs are listed under. */ -const FIREFOX = 'Firefox'; +import { runProgram } from '../runProgram'; +import type { FoundBrowser } from '../types'; +import { listFirefoxTabs } from './listFirefoxTabs'; +import { listScriptableTabs, SCRIPTING_PLATFORM } from './scriptable'; /** - * The tabs open in your everyday Firefox, profile by profile, as its session file has them (it is kept up to date while - * Firefox runs, and holds the last session once it has quit). Read when asked, here only; profiles with none are left out. + * The tabs open in your everyday browsers, read when asked and here only: Firefox's from its session files, and on + * macOS, those of the running browsers among `browsers` that scripting reaches (Safari, Chrome, Edge, Brave, Arc…). */ -export async function listEverydayTabs(home = homedir(), platform = process.platform): Promise { - const found: EverydayBrowser[] = []; - for (const dataDir of (FIREFOX_DATA_DIRS[platform] ?? []).map((dir) => join(home, dir))) { - const ini = await readFile(join(dataDir, PROFILES_INI), 'utf8').catch(() => ''); - for (const profile of profilesOf(dataDir, ini)) { - const tabs = sessionTabsOf((await readMozLz4(join(profile.dir, ...SESSION_FILE))) ?? ''); - if (tabs.length) found.push({ id: profile.dir, name: FIREFOX, profile: profile.name, tabs }); - } - } - return found; +export async function listEverydayTabs(browsers: readonly FoundBrowser[], home = homedir(), platform: NodeJS.Platform = process.platform, run = runProgram): Promise { + const [firefox, scripted] = await Promise.all([listFirefoxTabs(home, platform), platform === SCRIPTING_PLATFORM ? listScriptableTabs(browsers, run) : []]); + return [...firefox, ...scripted]; } diff --git a/src/main/browsers/everyday/listFirefoxTabs.ts b/src/main/browsers/everyday/listFirefoxTabs.ts new file mode 100644 index 0000000..9275f9d --- /dev/null +++ b/src/main/browsers/everyday/listFirefoxTabs.ts @@ -0,0 +1,26 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { EverydayBrowser } from '../../../shared/types'; +import { FIREFOX_DATA_DIRS, PROFILES_INI, SESSION_FILE } from './constants'; +import { profilesOf } from './profilesOf'; +import { readMozLz4 } from './readMozLz4'; +import { sessionTabsOf } from './sessionTabsOf'; + +/** The browser name everyday Firefox tabs are listed under. */ +const FIREFOX = 'Firefox'; + +/** + * The tabs open in your everyday Firefox, profile by profile, as its session file has them (it is kept up to date while + * Firefox runs, and holds the last session once it has quit). Profiles with none are left out. + */ +export async function listFirefoxTabs(home: string, platform: NodeJS.Platform): Promise { + const found: EverydayBrowser[] = []; + for (const dataDir of (FIREFOX_DATA_DIRS[platform] ?? []).map((dir) => join(home, dir))) { + const ini = await readFile(join(dataDir, PROFILES_INI), 'utf8').catch(() => ''); + for (const profile of profilesOf(dataDir, ini)) { + const tabs = sessionTabsOf((await readMozLz4(join(profile.dir, ...SESSION_FILE))) ?? ''); + if (tabs.length) found.push({ id: profile.dir, name: FIREFOX, profile: profile.name, tabs }); + } + } + return found; +} diff --git a/src/main/browsers/everyday/listsTabs.ts b/src/main/browsers/everyday/listsTabs.ts new file mode 100644 index 0000000..7d63e0a --- /dev/null +++ b/src/main/browsers/everyday/listsTabs.ts @@ -0,0 +1,7 @@ +import type { FoundBrowser } from '../types'; +import { SCRIPTING_PLATFORM, scriptableApp } from './scriptable'; + +/** Whether a browser's everyday tabs can be listed: Firefox's from its session file, and on macOS those scripting reaches. */ +export function listsTabs(browser: FoundBrowser, platform: NodeJS.Platform = process.platform): boolean { + return browser.engine === 'gecko' || (platform === SCRIPTING_PLATFORM && scriptableApp(browser) !== null); +} diff --git a/src/main/browsers/everyday/scriptable/constants.ts b/src/main/browsers/everyday/scriptable/constants.ts new file mode 100644 index 0000000..8afa37a --- /dev/null +++ b/src/main/browsers/everyday/scriptable/constants.ts @@ -0,0 +1,36 @@ +/** + * The macOS browsers whose tabs scripting reaches (JavaScript for Automation), by app name, with what their tabs call + * a title: Safari's `name`, Chromium's `title`. Firefox has no tabs to script (its session file is read instead). + */ +export const SCRIPTABLE_APPS: Readonly> = { + Safari: 'name', + 'Safari Technology Preview': 'name', + 'Google Chrome': 'title', + 'Google Chrome Beta': 'title', + 'Google Chrome Dev': 'title', + 'Google Chrome Canary': 'title', + Chromium: 'title', + 'Microsoft Edge': 'title', + 'Microsoft Edge Beta': 'title', + 'Microsoft Edge Dev': 'title', + 'Microsoft Edge Canary': 'title', + 'Brave Browser': 'title', + 'Brave Browser Beta': 'title', + 'Brave Browser Nightly': 'title', + Vivaldi: 'title', + Arc: 'title', +}; + +/** The system that has it, and an app bundle's extension. */ +export const SCRIPTING_PLATFORM = 'darwin'; +export const APP_BUNDLE = '.app'; + +/** Runs a script as JavaScript for Automation: `osascript -l JavaScript -e `], + '/design.html': ['text/html', `-`], + '/app.js': ['text/javascript', "window.appValue = 'upstream';"], + '/api': ['application/json', '{}'], + '/blocked.js': ['text/javascript', ''], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0], 'set-cookie': 'visited=yes; Path=/' }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + origin = `http://127.0.0.1:${port}`; + other = `http://localhost:${port}`; + userData = await mkdtemp(join(tmpdir(), 'console-editor-playwright-')); + overrides.push( + override(`${origin}/app.js`, 'Script', "window.appValue = 'overridden';"), + override(`${other}/data`, 'Fetch', 'from-override', { request: { method: 'PUT', operation: '' }, response: answered }), + override(`${other}/graphql`, 'Fetch', 'from-graphql', { request: { method: 'POST', operation: 'GetUser' }, response: answered }), + ); + const rule = { enabled: true, resourceTypes: [], createdAt: now, updatedAt: now }; + rules.push({ ...rule, id: 'r1', action: 'headers', match: defaultMatcherFor(`${origin}/api`), headers: [{ operation: 'set', name: 'x-rule', value: 'yes' }] }, { ...rule, id: 'r2', action: 'block', match: defaultMatcherFor(`${origin}/blocked.js`) }); + const deps = { listedAs: { id: browser.id, name: browser.name, everyday: false }, home: userData, sources, userData, changed: () => undefined, closed: () => closed++ }; + driver = await connectPlaywright({ type: chromium, executablePath: chromium.executablePath(), headless: true }, browser, deps); + }); + + afterAll(async () => { + driver?.stop(); + await new Promise((r) => server?.close(r)); + await rm(userData, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('serves the overrides and applies the rules, a GraphQL operation answered by the body sent', async () => { + await driver.open(`${origin}/page.html`); + await waitFor(async () => (await titled(`${origin}/page.html`)) === 'overridden yes blocked from-override from-graphql upstream'); + expect(driver.list()).toMatchObject({ id: browser.id, browserId: browser.id, name: 'WebKit', everyday: false, version: expect.stringMatching(/^\d+\./) }); + }); + + it('serves overrides as they change once its tabs reload', async () => { + overrides[0] = { ...overrides[0], content: "window.appValue = 'changed';", updatedAt: Date.now() }; + await driver.refresh(); + await driver.reload(); + await waitFor(async () => (await titled(`${origin}/page.html`)).startsWith('changed yes blocked')); + }); + + it('captures a tab, and the whole page at a viewport given (in a context made for it)', async () => { + const [tab] = driver.list().tabs; + const { image } = await driver.capture(tab.id, 'viewport'); + expect(decodePng(image.bytes).at(5, 5)).toEqual([255, 0, 0, 255]); + const { image: at } = await driver.captureAt(`${origin}/page.html`, { width: 400, height: 300, scale: 2 }); + expect(at).toMatchObject({ width: 800, height: 600, scale: 2, viewport: { width: 400, height: 300 } }); + expect(driver.list().tabs).toHaveLength(1); + }); + + it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { + const url = `${origin}/design.html`; + await driver.open(url); + const settings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true } as const; + const design = { key: 'd1', base64: encodePng(40, 30, () => [255, 0, 0]).toString('base64'), width: 300, height: 200, settings }; + await driver.setDesign(design); + await waitFor(async () => (await titled(url)) === 'design 0.5 300'); + const tab = driver.list().tabs.find((t) => t.url === url)!; + const { image } = await driver.capture(tab.id, 'viewport'); + expect(decodePng(image.bytes).at(5, 5)).toEqual([0, 0, 255, 255]); + expect(image.viewport.width).not.toBe(300); + await waitFor(async () => (await titled(url)) === 'design 0.5 300'); + await driver.reload(); + await waitFor(async () => (await titled(url)) === 'design 0.5 300'); + await driver.setDesign(null); + await waitFor(async () => /^no design \d+$/.test(await titled(url)) && !(await titled(url)).endsWith(' 300')); + }); + + it('quits it once let go of, keeping its cookies for next time', async () => { + driver.stop(); + const state = join(userData, 'browsers', 'playwright_webkit', 'storage-state.json'); + await waitFor(() => existsSync(state)); + expect((JSON.parse(await import('node:fs/promises').then((fs) => fs.readFile(state, 'utf8'))) as { cookies: Array<{ name: string }> }).cookies.map((c) => c.name)).toContain('visited'); + expect(closed).toBe(0); + }); +}); diff --git a/test/unit/browsers.test.ts b/test/unit/browsers.test.ts index 7436340..845753a 100644 --- a/test/unit/browsers.test.ts +++ b/test/unit/browsers.test.ts @@ -251,7 +251,7 @@ describe('The registry', () => { it('lists what it found, looks again only after a minute, and announces versions once read', async () => { const { registry: r, events, find } = await registry(); - expect(await r.list()).toEqual([{ id: found.id, name: 'Fake', engine: 'chromium', version: null, icon: null, added: false, hidden: false, debuggable: false, listsTabs: false }]); + expect(await r.list()).toEqual([{ id: found.id, name: 'Fake', engine: 'chromium', version: null, icon: null, added: false, hidden: false, debuggable: false, listsTabs: false, build: null }]); await r.list(); expect(find).toHaveBeenCalledTimes(1); await expect.poll(() => events.length).toBe(1); @@ -266,10 +266,26 @@ describe('The registry', () => { await expect(r.open('desktop:unknown.desktop', 'https://shop.test/')).rejects.toThrow('no longer there'); }); + it('lists a build the app downloads (WebKit) with its version, whether downloaded, and opens it with changes only', async () => { + const prefs = new BrowserStore(join(mkdtempSync(join(tmp, 'registry-')), 'browsers.json')); + await prefs.load(); + const events: Array<{ type: string; browsers?: Array<{ id: string; build: unknown }> }> = []; + let downloaded = false; + const webkit: FoundBrowser = { id: 'playwright:webkit', name: 'WebKit', engine: 'webkit', command: [], urlAt: 0, iconFile: null, app: null, program: join(tmp, 'pw_run.sh'), added: false }; + const builds = { list: async () => [{ browser: webkit, version: '26.6', downloaded }] }; + const r = new BrowserRegistry({ prefs, send: (e) => events.push(e as (typeof events)[number]), find: async () => [found], home: tmp, builds }); + expect((await r.list()).find((b) => b.id === webkit.id)).toMatchObject({ name: 'WebKit', engine: 'webkit', version: '26.6', build: { downloaded: false } }); + await expect(r.open(webkit.id, 'https://shop.test/')).rejects.toThrow('WebKit opens with your changes only'); + downloaded = true; + await r.buildsChanged(); + expect(events.at(-1)?.browsers?.find((b) => b.id === webkit.id)).toMatchObject({ build: { downloaded: true } }); + expect(await r.get(webkit.id)).toEqual(webkit); + }); + it('adds a program, hides and removes it, and refuses what can’t be run', async () => { const { registry: r, events } = await registry(); const added = await r.add(fake); - expect(added).toEqual({ id: expect.stringMatching(/^added:[0-9a-f]{8}$/), name: 'fake-browser', engine: 'unknown', version: null, icon: null, added: true, hidden: false, debuggable: false, listsTabs: false }); + expect(added).toEqual({ id: expect.stringMatching(/^added:[0-9a-f]{8}$/), name: 'fake-browser', engine: 'unknown', version: null, icon: null, added: true, hidden: false, debuggable: false, listsTabs: false, build: null }); await r.setHidden(added.id, true); expect((await r.list()).find((b) => b.id === added.id)?.hidden).toBe(true); await r.remove(added.id); diff --git a/test/unit/webkitDownload.test.ts b/test/unit/webkitDownload.test.ts new file mode 100644 index 0000000..6b99ee0 --- /dev/null +++ b/test/unit/webkitDownload.test.ts @@ -0,0 +1,106 @@ +/** + * The WebKit build the app downloads (Playwright's): where it goes and comes from, as the pinned playwright-core's + * registry says; downloaded from a stand-in host with its progress, the next mirror tried when one fails, unpacked + * with its program made runnable and its marker written last; nothing left of one that failed; and listed, downloaded + * and removed as a browser of its own. + */ +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { installWebKit } from '../../src/main/browsers/webkit/installWebKit'; +import { isInstalled } from '../../src/main/browsers/webkit/isInstalled'; +import { WebKitDownload } from '../../src/main/browsers/webkit'; +import { webkitBuild } from '../../src/main/browsers/webkit/webkitBuild'; +import type { WebKitBuild } from '../../src/main/browsers/webkit/types'; +import type { AppEvent } from '../../src/shared/types'; +import { zipOf } from '../helpers/zipOf'; + +const tmp = mkdtempSync(join(tmpdir(), 'console-editor-webkit-')); + +/** A zip holding a stand-in build: its program (not yet runnable) and a library. */ +const buildZip = (program: string) => + zipOf([ + { name: program, data: Buffer.from('#!/bin/sh\nexit 1\n'), mode: 0o100644 }, + { name: 'lib/libwebkit.so', data: Buffer.alloc(200_000, 7) }, + ]); + +describe('The WebKit build', () => { + let server: Server; + let host: string; + let zip: Buffer; + const asked: string[] = []; + + beforeAll(async () => { + zip = await buildZip('pw_run.sh'); + server = createServer((req, res) => { + asked.push(req.url!); + if (req.url!.startsWith('/missing/')) return void res.writeHead(404).end(); + // Says it is longer than it is: cut short. + if (req.url!.startsWith('/short/')) return void res.writeHead(200, { 'content-length': String(zip.length + 10) }).end(zip); + res.writeHead(200, { 'content-length': String(zip.length) }).end(zip); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + host = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + // Where the app's download of it comes from (read by Playwright's registry as it loads). + process.env.PLAYWRIGHT_DOWNLOAD_HOST = host; + }); + + afterAll(async () => { + delete process.env.PLAYWRIGHT_DOWNLOAD_HOST; + await new Promise((r) => server.close(r)); + rmSync(tmp, { recursive: true, force: true }); + }); + + const build = (name: string, urls: string[]): WebKitBuild => ({ directory: join(tmp, name, 'webkit-1'), executable: join(tmp, name, 'webkit-1', 'pw_run.sh'), urls, version: '26.6' }); + + it("is where the pinned playwright-core's registry puts it, under the app's folder, and comes from its host", async () => { + const found = await webkitBuild(join(tmp, 'builds')); + const { revision, browserVersion } = (JSON.parse(readFileSync(join(__dirname, '../../node_modules/playwright-core/browsers.json'), 'utf8')) as { browsers: Array<{ name: string; revision: string; browserVersion: string }> }).browsers.find((b) => b.name === 'webkit')!; + expect(dirname(found.directory)).toBe(join(tmp, 'builds')); + expect(basename(found.directory)).toMatch(new RegExp(`^webkit-(${revision}|\\d+)$`)); + expect(found.executable.startsWith(found.directory)).toBe(true); + expect(found.version).toBe(browserVersion); + expect(found.urls).toEqual([expect.stringMatching(new RegExp(`^${host}/builds/webkit/\\d+/webkit-.+\\.zip$`))]); + }); + + it('is downloaded with its progress, unpacked, its program made runnable and its marker written last', async () => { + const target = build('ok', [`${host}/missing/webkit.zip`, `${host}/builds/webkit.zip`]); + const progress: Array<[number, number | null]> = []; + await installWebKit(target, (done, total) => progress.push([done, total])); + expect(progress.at(-1)).toEqual([zip.length, zip.length]); + expect(asked.slice(-2)).toEqual(['/missing/webkit.zip', '/builds/webkit.zip']); + expect(statSync(target.executable).mode & 0o777).toBe(0o755); + expect(statSync(join(target.directory, 'lib/libwebkit.so')).size).toBe(200_000); + expect(existsSync(join(target.directory, 'INSTALLATION_COMPLETE'))).toBe(true); + expect(await isInstalled(target)).toBe(true); + }); + + it('leaves nothing of a download that failed, or was cut short', async () => { + const missing = build('missing', [`${host}/missing/a.zip`, `${host}/missing/b.zip`]); + await expect(installWebKit(missing, () => undefined)).rejects.toThrow('The download failed (404)'); + expect(existsSync(missing.directory)).toBe(false); + const short = build('short', [`${host}/short/webkit.zip`]); + await expect(installWebKit(short, () => undefined)).rejects.toThrow(); + expect(existsSync(short.directory)).toBe(false); + expect(await isInstalled(short)).toBe(false); + }); + + it('is listed as a browser of its own, downloaded when asked (its progress announced), and removed', async () => { + const events: AppEvent[] = []; + let changes = 0; + const webkit = new WebKitDownload({ dir: join(tmp, 'app'), send: (event) => events.push(event), changed: () => changes++ }); + const [listed] = await webkit.list(); + expect(listed).toMatchObject({ browser: { id: 'playwright:webkit', name: 'WebKit', engine: 'webkit', command: [] }, downloaded: false, version: expect.stringMatching(/^\d+\.\d+/) }); + await Promise.all([webkit.download(), webkit.download()]); + expect(asked.filter((url) => url.startsWith('/builds/webkit/'))).toHaveLength(1); + expect(events.at(-1)).toEqual({ type: 'browser-download', id: 'playwright:webkit', done: zip.length, total: zip.length }); + expect(changes).toBe(1); + expect((await webkit.list())[0].downloaded).toBe(true); + await webkit.remove(); + expect((await webkit.list())[0].downloaded).toBe(false); + expect(changes).toBe(2); + }); +});