diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bca866..fb90b58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,8 +142,9 @@ jobs: registry-url: https://registry.npmjs.org - name: Update npm - # Trusted publishing needs npm 11.5.1 or later; Node 22 comes with npm 10. - run: npm install -g npm@11 + # Trusted publishing needs npm 11.5.1 or later, and 11.21.0 for npm dist-tag; Node 22 comes + # with npm 10. + run: npm install -g npm@^11.21.0 - name: Publish to npm run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e974f1f..8e0ec0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All packages share one version and are released together (see DEV.md, "Releasing ## Unreleased +- From jsDelivr or unpkg, the probe needs no other attribute: it loads the frontend of its own version from there, and shell mode works (without the Service Worker). Shell configs from such a CDN are trusted only in the probe's package and the frontend's at that version, no longer anywhere on the CDN. `data-no-panel` goes without the panel: a page from such a CDN only debugged remotely (`data-remote`) needs it, or it now gets a panel too. +- The docked panel is resized with a grip on the middle of its inner edge, on the page's side, in place of the bar across its whole edge: it covers neither DevTools nor more of the page than it shows, and looks the same in and out of shell mode. The undocked window keeps its bar, which moves it. +- Device mode in shell mode, as in Chrome: DevTools lays out the page and itself, the toolbar's device button sizes the page as a device (scaled to fit) and its menu takes screenshots, docked or undocked. It sets the page's size only, not its pixel ratio, user agent or touch input. A grip against the middle of DevTools' edge, on the page's side, resizes them without covering DevTools, larger on touch screens. Outside shell mode the button is still the screencast's. +- `console.table` of objects shows their fields as columns, as in Chrome, instead of a single "Value" column; columns given as an array filter them. +- `Page.captureScreenshot` and `Page.getLayoutMetrics`: screenshots of the viewport, a region or the whole page, rendered by snapDOM and marked `approximate: true`, for tools and agents that drive the page over CDP. + ## 0.1.0 Based on Chrome 156 (156.0.8078.80). diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index b2421cf..cdd3d6b 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -4,6 +4,12 @@ ## Unreleased +- 探针来自 jsDelivr 或 unpkg 时,不用再写其他属性:它会从同一个 CDN 加载同版本的前端,外壳模式也能用(没有 Service Worker)。来自这类 CDN 时,外壳配置只信任探针所在的包和同版本的前端包,不再信任整个 CDN。新增 `data-no-panel`,不挂载面板:从这类 CDN 引入、只做远程调试(`data-remote`)的页面需要加上它,否则现在也会出现面板。 +- 停靠的面板改用内侧边缘正中、靠页面一侧的抓手调整大小,取代原来贯穿整条边的横条:它不遮挡 DevTools,也只占页面上它看得见的那一小块,外壳模式内外样式一致。浮动窗口仍保留用来移动的横条。 +- 外壳模式下新增设备模式,和 Chrome 一样:由 DevTools 排布页面和自己,工具栏上的设备按钮按设备尺寸设置页面(缩放到适合窗口),它的菜单可以截图,停靠和取消停靠时都能用。它只设置页面的尺寸,不改变像素比、UA 和触摸输入。DevTools 面板边缘正中、靠页面一侧有一个抓手,用来调整大小,不遮挡 DevTools,触屏上更大。非外壳模式下,这个按钮仍然是投屏。 +- `console.table` 传入对象时,和 Chrome 一样按字段分列显示,不再只有一列「Value」;以数组给出列名时只显示这些列。 +- 新增 `Page.captureScreenshot` 和 `Page.getLayoutMetrics`:可以截取视口、指定区域或整个页面,由 snapDOM 绘制,并标注 `approximate: true`,供通过 CDP 操作页面的工具和 agent 使用。 + ## 0.1.0 基于 Chrome 156(156.0.8078.80)。 diff --git a/DEV.md b/DEV.md index 52e3568..dcf3f49 100644 --- a/DEV.md +++ b/DEV.md @@ -2,7 +2,7 @@ English | [简体中文](DEV.zh-CN.md) -How to set up a development environment, work on the packages with live rebuilds, and get changes merged or released. For what the project does and how it is designed, see the [README](README.md). +How to set up a development environment, work on the packages with live rebuilds, and get changes merged or released. For what the project does, see the [README](README.md); for how it works and why, the [design](docs/design.md). ## 1. First-time setup @@ -88,6 +88,7 @@ tests/ tests that span packages e2e/ probe → CDP → frontend support/ launches Chrome with the static server playground/ pages and server for development +docs/ usage guide, design and the README's screenshots experiments/ validation records ``` @@ -122,7 +123,7 @@ What two pieces of code must agree on is written once, where it is owned, and im |---|---|---| | Relay messages (hello, pagehide, detach, ping), hello's params, the replaced-page close code | `packages/relay-core/src/protocol.ts` | `remote.ts`, `connection.ts`, remote-server's `relay.ts` | | Frontend ↔ ``: requests, dock sides, hash parameters, our scripts' file names | `packages/devtools-anywhere/src/embedder.ts` | the element, the build; `anywhere_app` through a copy made by the frontend build | -| Shell mode's messages between the page and its Service Worker | `packages/devtools-anywhere/src/shell-messages.ts`, and the agent's `probe/messages.ts` | `element.ts`, `probe.ts`, `service-worker.ts`, the probe | +| Shell mode's messages between the page and its Service Worker | `packages/devtools-anywhere/src/shell-messages.ts`, and the agent's `probe/messages.ts` | `element/index.ts`, `probe.ts`, `service-worker.ts`, the probe | | `window.DevtoolsAnywhere`, the API the bundle exposes (`RemoteOptions` included) | `packages/devtools-anywhere/src/api.ts` | `iife.ts`, `probe.ts`, `tests/globals.d.ts` | | The probe script's `data-*` attributes | `packages/devtools-anywhere/src/probe-script.ts` (`ProbeScriptAttributes`, exported as `@public`) | `probe.ts`, the Vite plugin, remote-server | @@ -147,8 +148,9 @@ A test sits next to what it tests, as `.test.ts`; only tests that span pac - **Own entrypoint** `anywhere_app` instead of `devtools_app`: it imports only the panels that work against the in-page CDP agent (Elements, Console, Sources, Network, Application) and the screencast, so the others are neither shown nor loaded. The only patch registers this entrypoint in the build (7 lines in 4 `BUILD.gn`/`.gni` files). - **Embedder through the official host API**: Chrome talks to DevTools through native `InspectorFrontendHost` bindings. The entrypoint keeps the hosted-mode stub and only replaces `sendMessageToBackend`/`isHostedMode`, so CDP messages travel through `postMessage` to the embedding window, which passes its origin as `#?embedded=`. No connection code is patched. +- **Device mode in shell mode**: the entrypoint loads the emulation module, whose app (upstream's `AdvancedApp`) lays out the page and DevTools as in Chrome, when the shell passes `#?shell=1` (`can_emulate_device`); elsewhere upstream's simple app and the screencast stay. `setInspectedPageBounds` goes to the shell (`page-bounds`), which places the page iframe and answers `Emulation.setDeviceMetricsOverride` by sizing it (`packages/devtools-anywhere/src/shell-layout.ts`). Undocked, device mode's page `device_mode_emulation_frame.html` is shown by the shell in an iframe instead of a window, and the entrypoint hands it its document. `patches/device-mode-in-shell.patch` adds the condition, keeps the screencast out of shell mode, opens device mode's page next to `anywhere_app.html`, and drops the device pixel ratio and device type from the toolbar's menu. - **Small screens**: upstream's responsive layouts (vertical split in Elements, overflowing tabs) are kept as is. -- **Few requests**: upstream ships one bundle per module, about 150 requests to open DevTools, which is slow over HTTP/1.1 or a flaky network. `scripts/bundle.ts` rebundles them with rollup (tree-shaken) into `dist/bundle/`: each lazily loaded module (a panel, mostly) is a chunk loaded when it is, and the entry chunk holds what the entry imports, what several panels share and the small ones; each worker is one file, and the SVG icons are inlined as data: URLs. Opening DevTools takes about 10 requests. Every bundled file is named after a hash of its content and can be cached for good; only `anywhere_app.html`, which names the entry, must be revalidated. Since modules are no files of their own any more, the end-to-end tests reach them through `modules` exported by the entrypoint. +- **Few requests**: upstream ships one bundle per module, about 150 requests to open DevTools, which is slow over HTTP/1.1 or a flaky network. `scripts/bundle.ts` rebundles them with rollup (tree-shaken) into `dist/bundle/`: each lazily loaded module (a panel, mostly) is a chunk loaded when it is, and the entry chunk holds what the entry imports, what several panels share and the small ones; each worker is one file, and so is device mode's page for undocked DevTools; the SVG icons are inlined as data: URLs. Opening DevTools takes about 10 requests. Every bundled file is named after a hash of its content and can be cached for good; only `anywhere_app.html`, which names the entry, must be revalidated. Since modules are no files of their own any more, the end-to-end tests reach them through `modules` exported by the entrypoint. `packages/devtools-frontend/` contains: @@ -195,34 +197,55 @@ pnpm test # with the frontend built, so the real-frontend tests run The pre-commit hook (husky + lint-staged) runs on the staged files only: it formats them with oxfmt and adds the result to the commit, then lints them with oxlint and stops the commit on any error; it also runs `scripts/check-contracts.ts` (see "Where contracts live"), and two size guards on what the commit adds or changes: `scripts/check-file-size.ts` warns on a source file (`.ts`, `.js` and their variants) past 1100 lines and rejects the commit past 1500; `scripts/check-dir-size.ts` warns on a directory the commit adds files to that holds more than 20 files directly and rejects it past 30. After a warning, make the next commit the one that splits the file or sorts the directory into subdirectories by concern. Both skip the code that goes into the devtools-frontend checkout, which follows Chromium's style (`packages/devtools-frontend/src` is copied in, `patches` are applied to it) and `experiments/`. `pnpm lint` runs both on every tracked file (`--all`), so a commit that skipped the hook is caught there. `git commit --no-verify` skips the hook once. Lint rules: oxlint's correctness rules, no `any`, no non-null assertions (`!`). Style: 2-space indentation, semicolons, single quotes, 120 columns. `packages/devtools-frontend/src` follows Chromium's style instead and is not linted or formatted. -The build rolls each package's type declarations up into one file per entry point (`dist/index.d.ts`) with API Extractor, and writes the package's API to `packages//etc/`: `*.public.api.md` for the public API (what is promised), `*.api.md` for everything exported, internal included (`scripts/api-extract.ts`, configured by `api-extractor.base.json`; it copies `declare global` blocks into the rolled-up file, which API Extractor leaves out). `devtools-anywhere` has no runtime entry: its `dist/index.d.ts` is types only, and its scripts are built as IIFEs. Commit a changed report with the change that caused it: it is how a change to the public API shows in review. With `CI=true` (for a CI, which the repository does not have yet) a report that no longer matches fails the build. Every export of an entry point needs a release tag: `@public` only for what the README documents (remote-server's `startRemoteServer` / `createRemoteHandler`, the Vite plugin), `@internal` for everything else, which is used only between this repository's packages, built and released together, and may change at any time; when in doubt, `@internal`, and export nothing an entry point does not need. Doc comments are TSDoc: put package names such as `@devtools-anywhere/...` in backticks and code examples in fenced blocks. +The build rolls each package's type declarations up into one file per entry point (`dist/index.d.ts`) with API Extractor, and writes the package's API to `packages//etc/`: `*.public.api.md` for the public API (what is promised), `*.api.md` for everything exported, internal included (`scripts/api-extract.ts`, configured by `api-extractor.base.json`; it copies `declare global` blocks into the rolled-up file, which API Extractor leaves out). `devtools-anywhere` has no runtime entry: its `dist/index.d.ts` is types only, and its scripts are built as IIFEs. Commit a changed report with the change that caused it: it is how a change to the public API shows in review. With `CI=true`, which GitHub Actions sets, a report that no longer matches fails the build, so CI catches a report left uncommitted. Every export of an entry point needs a release tag: `@public` only for what the README documents (remote-server's `startRemoteServer` / `createRemoteHandler`, the Vite plugin), `@internal` for everything else, which is used only between this repository's packages, built and released together, and may change at any time; when in doubt, `@internal`, and export nothing an entry point does not need. Doc comments are TSDoc: put package names such as `@devtools-anywhere/...` in backticks and code examples in fenced blocks. -Also update both `README.md` and `README.zh-CN.md` (and this guide in both languages) when behavior, usage or limitations change, and add a line for users under `Unreleased` in both `CHANGELOG.md` and `CHANGELOG.zh-CN.md` (moving the frontend to a new Chrome milestone also changes its `Based on Chrome` line). +Also update both `README.md` and `README.zh-CN.md` (and this guide, `docs/usage.md` and `docs/design.md` in both languages) when behavior, usage or limitations change, and add a line for users under `Unreleased` in both `CHANGELOG.md` and `CHANGELOG.zh-CN.md` (moving the frontend to a new Chrome milestone also changes its `Based on Chrome` line). When a dependency that ends up in a build output is added or upgraded, or code is ported from another project (including moving the DevTools frontend to a new `REVISION`), run `pnpm licenses:update` to regenerate `THIRD_PARTY_LICENSES.md` at the root and in the packages (for a new library, add it to the list in `scripts/third-party-licenses.ts` first). It needs the frontend built: the build writes the `front_end/third_party` libraries that end up in `dist/` to `packages/devtools-frontend/dist-third-party.ignore.json` (next to `dist/`, not published), which the script checks its list against; `build:frontend --copy-only` regenerates it from the last build. ### Commits - One logical step per commit. -- Message format: `type(scope): summary`, then a blank line and an English body explaining what changed and why. Types: `feat`, `fix`, `docs`, `test`, `build`, `refactor`, `chore`. Scopes are package or area names, e.g. `cdp-agent`, `network`, `devtools-frontend`, `service-worker`, `readme`. +- Message format: `type(scope): summary`, then a blank line and an English body explaining what changed and why. Types: `feat`, `fix`, `docs`, `test`, `build`, `ci`, `refactor`, `chore`. Scopes are package or area names, e.g. `cdp-agent`, `network`, `devtools-frontend`, `service-worker`, `readme`. -### Pull requests +### Branches and pull requests + +`main` is the only long-lived branch: it is where all work lands and what releases are tagged from, and it should always be releasable. There is no `develop` branch: merging into `main` does not release anything (only a pushed `vx.y.z` tag does, see "Releasing to npm"), so `main` itself holds what is merged and not yet released. + +| Branch | For | Rules | +| --- | --- | --- | +| `main` | All development; releases are tagged on it | Changed through pull requests only, CI must pass, no force pushes | +| `feat/…`, `fix/…`, `docs/…`, `ci/…`, … | One change each, short-lived; the prefix is the commit type | Branched from `main`, merged back through a pull request and deleted | +| `release/x.y.z` | The pull request that prepares release x.y.z (version and changelog) | Merged into `main`, then the tag goes on `main` | +| `release/x.y` | Patches to an older minor, only when needed (see "Patching an older release") | Branched from the tag `vx.y.0`, kept while that minor is patched | + +A fix is not a branch type of its own: it goes through a `fix/…` branch into `main` like any change, and is released as a patch. CI (`.github/workflows/ci.yml`) runs on every pull request and every push to `main`: lint, typecheck, the frontend build and all the tests. ```bash -git switch -c my-change +git switch -c feat/my-change main # … commits … -git push -u origin my-change +git push -u origin feat/my-change gh pr create --fill ``` -Describe in the PR what was verified, in particular what was checked by hand in a browser and what was not (Safari, mobile devices). +Describe in the PR what was verified, in particular what was checked by hand in a browser and what was not (Safari, mobile devices). A change users notice also adds its line under `Unreleased` in both changelogs in the same pull request, so that the changelog is ready when a release is cut. ### Releasing to npm All packages share one version number and are released together, by `.github/workflows/release.yml` when a tag `vx.y.z` is pushed. npm trusts that workflow (trusted publishing, set on each package on npmjs.com for this repository, `release.yml` and the `npm` environment), so it publishes without a stored token and with provenance; renaming the file or the environment breaks that. Approving the publish takes a reviewer of the repository's `npm` environment. Each package's trusted publisher must allow `npm publish` and `npm dist-tag`, not only `npm stage publish`. -Versions are semver for the library itself, starting at `0.1.0`: a fix is a patch, and moving the frontend to a new Chrome milestone is a minor (its UI changes); before 1.0 a minor may also break. What is promised is what the README documents (`@public`). The Chrome version is not in the version number, where npm would drop it (`+chrome156`) or take it for a prerelease (`-chrome156`): each published package's `package.json` has it as `chrome`, which `update` writes, and each release says it first (`Based on Chrome 156`). Each release also gets the dist-tag `chrome-`, so that `npm i devtools-anywhere@chrome-156` installs the last release on that Chrome. +Versions are semver for the library itself, starting at `0.1.0`: a fix is a patch; a new feature, or moving the frontend to a new Chrome milestone (its UI changes), is a minor; before 1.0 a minor may also break. Releases are cut when there is something worth releasing (features that add up to a minor, a fix users are waiting for, a new Chrome milestone), not on every merge. What is promised is what the README documents (`@public`). The Chrome version is not in the version number, where npm would drop it (`+chrome156`) or take it for a prerelease (`-chrome156`): each published package's `package.json` has it as `chrome`, which `update` writes, and each release says it first (`Based on Chrome 156`). Each release also gets the dist-tag `chrome-`, so that `npm i devtools-anywhere@chrome-156` installs the last release on that Chrome. -1. Set the same `version` in every `packages/*/package.json`, move the `Unreleased` notes in `CHANGELOG.md` and `CHANGELOG.zh-CN.md` under `## x.y.z`, and commit (`build: release x.y.z`). `node scripts/release.ts check x.y.z` checks both. -2. Tag and push: `git tag vx.y.z && git push origin main vx.y.z`. +1. On a branch `release/x.y.z` from `main`, set the same `version` in every `packages/*/package.json`, move the `Unreleased` notes in `CHANGELOG.md` and `CHANGELOG.zh-CN.md` under `## x.y.z`, and commit (`build: release x.y.z`). `node scripts/release.ts check x.y.z` checks both. Open a pull request and merge it once CI passes. +2. Tag the merged commit on `main` and push the tag: `git switch main && git pull && git tag vx.y.z && git push origin vx.y.z`. 3. The workflow checks the version, builds the frontend, runs the checks and tests as CI does and packs the packages (pnpm replaces `workspace:*` with the real version). Then approve its `publish` job in the Actions tab: it publishes the packages to npm, tags them `chrome-`, and publishes the GitHub release with that version's section of `CHANGELOG.md` followed by that of `CHANGELOG.zh-CN.md`. + +#### Patching an older release + +Usually a fix is released as the next patch from `main`. Only when `main` already holds changes that should not ship yet (a new minor in progress) and an older release needs the fix: + +1. Create `release/x.y` from the tag `vx.y.0` once, if it does not exist yet: `git switch -c release/x.y vx.y.0 && git push -u origin release/x.y`. +2. Merge the fix into `main` first, then cherry-pick it onto `release/x.y` through a pull request (`git cherry-pick -x `), with its changelog line. +3. Release from `release/x.y` as above, with the tag on that branch. + +Not set up yet, to do before the first such release: CI does not run on `release/**` branches, and the publish job moves npm's `latest` to whatever it publishes, so a patch to an older minor would replace the newer release as `latest`; it has to publish with `--tag` (or move `latest` back) instead. diff --git a/DEV.zh-CN.md b/DEV.zh-CN.md index 33fdcd2..9d61492 100644 --- a/DEV.zh-CN.md +++ b/DEV.zh-CN.md @@ -2,7 +2,7 @@ [English](DEV.md) | 简体中文 -介绍如何搭建开发环境、在实时重新构建下开发各个包,以及改动如何合入或发布。项目做什么、怎么设计的,见 [README](README.zh-CN.md)。 +介绍如何搭建开发环境、在实时重新构建下开发各个包,以及改动如何合入或发布。项目做什么见 [README](README.zh-CN.md),工作原理和设计取舍见[设计文档](docs/design.zh-CN.md)。 ## 1. 第一次开发:环境配置 @@ -88,6 +88,7 @@ tests/ 跨包的测试 e2e/ 探针 → CDP → 前端的完整流程 support/ 启动 Chrome 和静态服务器 playground/ 开发调试用的页面和服务器 +docs/ 使用指南、设计文档和 README 的截图 experiments/ 验证记录 ``` @@ -122,7 +123,7 @@ experiments/ 验证记录 |---|---|---| | 中继消息(hello、pagehide、detach、ping)、hello 的参数、页面被顶替时的关闭码 | `packages/relay-core/src/protocol.ts` | `remote.ts`、`connection.ts`、remote-server 的 `relay.ts` | | 前端 ↔ ``:请求、停靠位置、hash 参数、我们自己脚本的文件名 | `packages/devtools-anywhere/src/embedder.ts` | 元素、构建脚本;`anywhere_app` 通过前端构建时复制的副本使用 | -| 外壳模式下页面与 Service Worker 之间的消息 | `packages/devtools-anywhere/src/shell-messages.ts`,以及 agent 的 `probe/messages.ts` | `element.ts`、`probe.ts`、`service-worker.ts`、探针 | +| 外壳模式下页面与 Service Worker 之间的消息 | `packages/devtools-anywhere/src/shell-messages.ts`,以及 agent 的 `probe/messages.ts` | `element/index.ts`、`probe.ts`、`service-worker.ts`、探针 | | `window.DevtoolsAnywhere`,bundle 暴露的 API(含 `RemoteOptions`) | `packages/devtools-anywhere/src/api.ts` | `iife.ts`、`probe.ts`、`tests/globals.d.ts` | | 探针脚本的 `data-*` 属性 | `packages/devtools-anywhere/src/probe-script.ts`(`ProbeScriptAttributes`,以 `@public` 导出) | `probe.ts`、Vite 插件、remote-server | @@ -147,8 +148,9 @@ pnpm typecheck # 先构建,再检查所有包 - **自有入口** `anywhere_app` 代替 `devtools_app`:只引入能对接页面内 CDP agent的面板(元素、控制台、源代码、网络、应用)和投屏,其余面板既不显示也不加载。唯一的补丁就是在构建配置里注册这个入口(4 个 `BUILD.gn`/`.gni` 文件,共 7 行)。 - **通过官方宿主接口接入**:Chrome 通过原生的 `InspectorFrontendHost` 与 DevTools 通信。入口保留托管模式的 stub,只替换 `sendMessageToBackend` 和 `isHostedMode`,让 CDP 消息经 `postMessage` 与嵌入它的窗口往来;嵌入方通过 `#?embedded=` 告知自己的 origin。连接相关代码一行都不改。 +- **外壳模式下的设备模式**:外壳传入 `#?shell=1` 时(`can_emulate_device`),入口加载 emulation 模块,由它的应用(上游的 `AdvancedApp`)像 Chrome 一样排布页面和 DevTools;其他情况仍用上游的简单应用和投屏。`setInspectedPageBounds` 发给外壳(`page-bounds`),外壳据此摆放页面 iframe,并通过设置 iframe 的尺寸来响应 `Emulation.setDeviceMetricsOverride`(`packages/devtools-anywhere/src/shell-layout.ts`)。取消停靠时,设备模式的页面 `device_mode_emulation_frame.html` 由外壳放在 iframe 里显示,不另开窗口,由入口把这个页面的 document 交给设备模式。`patches/device-mode-in-shell.patch` 加入这个条件,让投屏不在外壳模式下出现,让设备模式的页面在 `anywhere_app.html` 旁边打开,并从工具栏菜单中去掉设备像素比和设备类型。 - **小屏**:沿用上游自带的响应式布局(元素面板窄屏时上下分栏、标签页溢出折叠)。 -- **少请求**:上游每个模块各打成一个包,打开 DevTools 要发约 150 个请求,在 HTTP/1.1 或网络不稳时很慢。`scripts/bundle.ts` 用 rollup 把它们重新打包(含摇树)到 `dist/bundle/`:每个懒加载的模块(主要是面板)各成一个 chunk,用到时才加载;入口 chunk 包含入口引入的模块、多个面板共用的模块和体积很小的模块;每个 worker 打成单个文件,SVG 图标内联成 data: URL。打开 DevTools 约 10 个请求。打包出的文件都以内容 hash 命名,可以永久缓存;只有指明入口的 `anywhere_app.html` 需要每次重新验证。由于模块不再是独立文件,端到端测试通过入口导出的 `modules` 访问它们。 +- **少请求**:上游每个模块各打成一个包,打开 DevTools 要发约 150 个请求,在 HTTP/1.1 或网络不稳时很慢。`scripts/bundle.ts` 用 rollup 把它们重新打包(含摇树)到 `dist/bundle/`:每个懒加载的模块(主要是面板)各成一个 chunk,用到时才加载;入口 chunk 包含入口引入的模块、多个面板共用的模块和体积很小的模块;每个 worker 打成单个文件,取消停靠时设备模式用的页面也一样;SVG 图标内联成 data: URL。打开 DevTools 约 10 个请求。打包出的文件都以内容 hash 命名,可以永久缓存;只有指明入口的 `anywhere_app.html` 需要每次重新验证。由于模块不再是独立文件,端到端测试通过入口导出的 `modules` 访问它们。 `packages/devtools-frontend/` 里有: @@ -195,34 +197,55 @@ pnpm test # 前端需已构建,真实前端测试才会运行 pre-commit 钩子(husky + lint-staged)只处理暂存的文件:先用 oxfmt 格式化并把结果加入这次提交,再用 oxlint 检查,有错误就中止提交;还会运行 `scripts/check-contracts.ts`(见「约定放在哪里」),以及两道针对本次新增或改动内容的规模守卫:`scripts/check-file-size.ts` 对超过 1100 行的源码文件(`.ts`、`.js` 及其变体)发出警告,超过 1500 行拒绝提交;`scripts/check-dir-size.ts` 对本次有新增文件的目录,直属文件超过 20 个时警告,超过 30 个拒绝提交。出现警告后,下一次提交就应拆分该文件,或把目录按关注点分进子目录。两者都跳过要放进 devtools-frontend 检出目录、沿用 Chromium 风格的代码(`packages/devtools-frontend/src` 会被复制进去,`patches` 会打到它上面)和 `experiments/`。`pnpm lint` 会对所有已跟踪文件运行这两道守卫(`--all`),绕过钩子的提交也能在那里查出来。`git commit --no-verify` 可以跳过一次钩子。lint 规则:oxlint 的 correctness 规则,禁止 `any`,禁止非空断言(`!`)。代码风格:2 空格缩进、加分号、单引号、行宽 120。`packages/devtools-frontend/src` 沿用 Chromium 的风格,不做 lint 和格式化。 -构建时用 API Extractor 把各包的类型声明按入口合并成单个文件(`dist/index.d.ts`),并把包的 API 写进 `packages/<包>/etc/`:`*.public.api.md` 是公开 API(对外的承诺),`*.api.md` 是全部导出(含内部)(`scripts/api-extract.ts`,配置见 `api-extractor.base.json`;API Extractor 会丢掉 `declare global` 块,脚本会把它们补进合并后的文件)。`devtools-anywhere` 没有运行时入口:它的 `dist/index.d.ts` 只有类型,脚本都构建成 IIFE。报告有变化时与引起它的改动一起提交:公开 API 的变化靠它在评审中看到。设置 `CI=true` 时(供 CI 使用,仓库目前还没有 CI),报告与 API 不一致会让构建失败。入口的每个导出都要有发布标签:`@public` 只给 README 写明的用法(remote-server 的 `startRemoteServer` / `createRemoteHandler`、Vite 插件),其余一律 `@internal`,只供本仓库各包之间使用(一起构建、一起发布),随时可改;拿不准就标 `@internal`,入口用不到的就不导出。文档注释按 TSDoc 写:`@devtools-anywhere/...` 这类包名放进反引号,代码示例放进围栏代码块。 +构建时用 API Extractor 把各包的类型声明按入口合并成单个文件(`dist/index.d.ts`),并把包的 API 写进 `packages/<包>/etc/`:`*.public.api.md` 是公开 API(对外的承诺),`*.api.md` 是全部导出(含内部)(`scripts/api-extract.ts`,配置见 `api-extractor.base.json`;API Extractor 会丢掉 `declare global` 块,脚本会把它们补进合并后的文件)。`devtools-anywhere` 没有运行时入口:它的 `dist/index.d.ts` 只有类型,脚本都构建成 IIFE。报告有变化时与引起它的改动一起提交:公开 API 的变化靠它在评审中看到。设置 `CI=true` 时(GitHub Actions 会自动设置),报告与 API 不一致会让构建失败,所以忘了提交的报告会被 CI 拦下。入口的每个导出都要有发布标签:`@public` 只给 README 写明的用法(remote-server 的 `startRemoteServer` / `createRemoteHandler`、Vite 插件),其余一律 `@internal`,只供本仓库各包之间使用(一起构建、一起发布),随时可改;拿不准就标 `@internal`,入口用不到的就不导出。文档注释按 TSDoc 写:`@devtools-anywhere/...` 这类包名放进反引号,代码示例放进围栏代码块。 -行为、用法或限制有变化时,同时更新 `README.md` 和 `README.zh-CN.md`(以及本指南的两种语言版本),并在 `CHANGELOG.md` 和 `CHANGELOG.zh-CN.md` 的 `Unreleased` 下各给用户写一行说明(前端升级到新的 Chrome 里程碑时,还要改它的 `Based on Chrome` 那一行)。 +行为、用法或限制有变化时,同时更新 `README.md` 和 `README.zh-CN.md`(以及本指南、`docs/usage.md` 和 `docs/design.md` 的两种语言版本),并在 `CHANGELOG.md` 和 `CHANGELOG.zh-CN.md` 的 `Unreleased` 下各给用户写一行说明(前端升级到新的 Chrome 里程碑时,还要改它的 `Based on Chrome` 那一行)。 新增或升级会进入产物的依赖、移植其它项目的代码(包括把 DevTools 前端换到新的 `REVISION`)时,运行 `pnpm licenses:update` 重新生成根目录和各包的 `THIRD_PARTY_LICENSES.md`(新增的库先加进 `scripts/third-party-licenses.ts` 的清单)。 ### 提交 - 每个提交只做一步。 -- 提交信息格式:`type(scope): 概要`,空一行后用英文写正文,说明改了什么、为什么改。type 有 `feat`、`fix`、`docs`、`test`、`build`、`refactor`、`chore`;scope 用包名或模块名,例如 `cdp-agent`、`network`、`devtools-frontend`、`service-worker`、`readme`。 +- 提交信息格式:`type(scope): 概要`,空一行后用英文写正文,说明改了什么、为什么改。type 有 `feat`、`fix`、`docs`、`test`、`build`、`ci`、`refactor`、`chore`;scope 用包名或模块名,例如 `cdp-agent`、`network`、`devtools-frontend`、`service-worker`、`readme`。 -### 提 PR +### 分支与 PR + +`main` 是唯一的长期分支:所有改动都合进这里,发布也在这里打 tag,它应当随时可以发布。不设 `develop` 分支:合进 `main` 不会发布任何东西(只有推送 `vx.y.z` tag 才会,见「发布到 npm」),`main` 本身就存放着已合并、尚未发布的改动。 + +| 分支 | 用途 | 规则 | +| --- | --- | --- | +| `main` | 所有开发;发布在它上面打 tag | 只能通过 PR 修改,CI 必须通过,禁止强推 | +| `feat/…`、`fix/…`、`docs/…`、`ci/…` 等 | 每个分支只做一件事,短期存在;前缀与提交类型一致 | 从 `main` 拉出,通过 PR 合回后删除 | +| `release/x.y.z` | 准备 x.y.z 发布的 PR(版本号和 changelog) | 合进 `main`,然后在 `main` 上打 tag | +| `release/x.y` | 只在需要时用来给旧的 minor 打补丁(见「给旧版本打补丁」) | 从 tag `vx.y.0` 拉出,在该 minor 需要补丁期间保留 | + +修复不单独设一类分支:和其它改动一样走 `fix/…` 分支合进 `main`,作为 patch 发布。CI(`.github/workflows/ci.yml`)在每个 PR 和每次推送到 `main` 时运行:lint、类型检查、构建前端、跑全部测试。 ```bash -git switch -c my-change +git switch -c feat/my-change main # … 提交 … -git push -u origin my-change +git push -u origin feat/my-change gh pr create --fill ``` -在 PR 里写明验证过什么,尤其是哪些在浏览器里手动确认过、哪些没有(Safari、真机)。 +在 PR 里写明验证过什么,尤其是哪些在浏览器里手动确认过、哪些没有(Safari、真机)。用户能感知的改动,在同一个 PR 里往两份 changelog 的 `Unreleased` 下各加一行,这样发布时 changelog 已经备好。 ### 发布到 npm 所有包使用同一个版本号,一起发布:推送 tag `vx.y.z` 后由 `.github/workflows/release.yml` 发布。npm 信任这个 workflow(可信发布,在 npmjs.com 上为每个包配置了本仓库、`release.yml` 和 `npm` 环境),所以发布时不需要保存令牌,并且带出处证明(provenance);改了文件名或环境名就会失效。批准发布需要仓库 `npm` 环境的审批人。每个包的可信发布方都要勾选允许 `npm publish` 和 `npm dist-tag`,不能只允许 `npm stage publish`。 -版本号按 semver 表示库本身的变化,从 `0.1.0` 开始:修 bug 升 patch;前端升级到新的 Chrome 里程碑升 minor(界面会变);1.0 之前 minor 也可以有破坏性变更。承诺的范围是 README 写明的用法(`@public`)。Chrome 版本不放进版本号:写成 `+chrome156` 会被 npm 丢掉,写成 `-chrome156` 会被当成预发布版。它记在每个发布包 `package.json` 的 `chrome` 字段里(由 `update` 写入),每次发布的说明也在开头写明(「基于 Chrome 156」)。每次发布还会打上 dist-tag `chrome-<里程碑>`,`npm i devtools-anywhere@chrome-156` 就能装到基于该版 Chrome 的最后一个版本。 +版本号按 semver 表示库本身的变化,从 `0.1.0` 开始:修 bug 升 patch;新功能、或前端升级到新的 Chrome 里程碑(界面会变)升 minor;1.0 之前 minor 也可以有破坏性变更。有值得发布的内容时才发版(攒够一个 minor 的功能、用户在等的修复、新的 Chrome 里程碑),不是每次合并都发。承诺的范围是 README 写明的用法(`@public`)。Chrome 版本不放进版本号:写成 `+chrome156` 会被 npm 丢掉,写成 `-chrome156` 会被当成预发布版。它记在每个发布包 `package.json` 的 `chrome` 字段里(由 `update` 写入),每次发布的说明也在开头写明(「基于 Chrome 156」)。每次发布还会打上 dist-tag `chrome-<里程碑>`,`npm i devtools-anywhere@chrome-156` 就能装到基于该版 Chrome 的最后一个版本。 -1. 把所有 `packages/*/package.json` 的 `version` 改成同一个版本,把 `CHANGELOG.md` 和 `CHANGELOG.zh-CN.md` 里 `Unreleased` 下的内容挪到 `## x.y.z` 下,然后提交(`build: release x.y.z`)。`node scripts/release.ts check x.y.z` 会检查这两项。 -2. 打 tag 并推送:`git tag vx.y.z && git push origin main vx.y.z`。 +1. 从 `main` 拉出 `release/x.y.z` 分支,把所有 `packages/*/package.json` 的 `version` 改成同一个版本,把 `CHANGELOG.md` 和 `CHANGELOG.zh-CN.md` 里 `Unreleased` 下的内容挪到 `## x.y.z` 下,然后提交(`build: release x.y.z`)。`node scripts/release.ts check x.y.z` 会检查这两项。开 PR,CI 通过后合并。 +2. 在 `main` 上给合并后的提交打 tag 并推送这个 tag:`git switch main && git pull && git tag vx.y.z && git push origin vx.y.z`。 3. workflow 会检查版本号、构建前端、像 CI 一样跑检查和测试,并打包(pnpm 会把 `workspace:*` 替换成实际版本)。然后在 Actions 页面批准它的 `publish` 任务:它把各包发布到 npm,打上 dist-tag `chrome-<里程碑>`,并发布 GitHub Release,说明是 `CHANGELOG.md` 中该版本的那一节,后面接上 `CHANGELOG.zh-CN.md` 的对应一节。 + +#### 给旧版本打补丁 + +通常修复直接从 `main` 作为下一个 patch 发布。只有当 `main` 上已经有暂时不该发布的改动(正在做的新 minor),而旧版本又需要这个修复时,才这样做: + +1. 如果还没有 `release/x.y`,从 tag `vx.y.0` 建一次:`git switch -c release/x.y vx.y.0 && git push -u origin release/x.y`。 +2. 先把修复合进 `main`,再通过 PR 把它 cherry-pick 到 `release/x.y`(`git cherry-pick -x `),连同它的 changelog 条目。 +3. 按上面的步骤从 `release/x.y` 发布,tag 打在这个分支上。 + +尚未就绪,第一次这样发布前要补上:CI 不在 `release/**` 分支上运行;publish 任务会把 npm 的 `latest` 移到它发布的版本,给旧 minor 打的补丁会顶替更新的版本成为 `latest`,需要改成用 `--tag` 发布(或之后把 `latest` 移回去)。 diff --git a/README.md b/README.md index 768529c..76888b2 100644 --- a/README.md +++ b/README.md @@ -2,212 +2,99 @@ English | [简体中文](README.zh-CN.md) -Real Chrome DevTools inside any web page, especially on mobile, where browsers have no developer tools. +Real Chrome DevTools inside any web page, phones included. -## Why - -Existing in-page debuggers fall short: - -| Tool | Limitation | -|---|---| -| eruda / vConsole | Only capture output after they initialize. They are large and usually loaded asynchronously, so the earliest logs and errors of a page are lost | -| mobile-devtools | A custom UI rather than Chrome DevTools; initialized through framework adapters, so it starts after the app code (based on its README, not verified) | -| Chii | Can capture early output, but setup and configuration are cumbersome, and its frontend is stuck on an old Chrome DevTools (around version 122) | - -Goals: - -1. **Never miss early output**: console messages, errors and network requests are visible from the very first script on the page. -2. **Zero-config setup**: a single ` - ... - -``` +## Quick start -From a CDN: jsDelivr and unpkg serve every npm package, so nothing needs hosting. Pin an exact version, the same for both packages. Only English and Chinese are available this way, since the other languages are in a separate package that has to be served from the frontend's directory. Shell mode is not available either, as it needs the probe, the bundle and the frontend from the page's origin (see [shell mode](#2-shell-mode-in-place-rewrite)): `data-default-shell-mode="off"` embeds DevTools directly in the page. +**Vite**, during `vite serve` only: -```html - - - - +```bash +npm i -D @devtools-anywhere/vite-plugin ``` -After the page loads, the probe loads `devtools-anywhere.iife.js` from next to itself (or `data-bundle`) and mounts its own `` under ``, next to ``, whatever `` elements the page has; there is one such global element per page, and another one only logs a warning. Optional attributes: `data-open-immediately` opens DevTools right away; `data-default-shell-mode="off"` stays out of shell mode, which the top-level page enters by default (`on`); `data-shell-mode-locked` keeps shell mode as `data-default-shell-mode` says, ignoring the user's choice (which stays stored) and hiding DevTools' switch, though a page that refuses to be framed is still shown on its own; `data-no-toggler` hides the F12 button, also in shell mode; `data-service-worker` enables the Service Worker in shell mode, which injects the probe into same-origin pages in the page iframe that do not include it and adds the request and response headers of same-origin requests (documents, stylesheets, scripts, images and so on); it is off by default, see its [requirements](#3-injection). - -### Vite - ```js // vite.config.js import devtoolsAnywhere from '@devtools-anywhere/vite-plugin' -export default { - plugins: [devtoolsAnywhere()], -} +export default { plugins: [devtoolsAnywhere()] } ``` -Only `vite serve` is affected: the probe is injected as the first script of every page, and the bundle, the Service Worker and the frontend are served under `/@devtools-anywhere/`. The frontend comes from `@devtools-anywhere/devtools-frontend`. Options: `devtoolsFrontendPath` (the entry page of another frontend build on disk, such as `…/anywhere_app.html`; its directory is served), `devtoolsFrontendUrl` (a frontend hosted elsewhere, served in place of it), `openImmediately`, `defaultShellMode` (`'on'` or `'off'`), `shellModeLocked`, `noToggler` (hides the F12 button), `serviceWorker` (see its [requirements](#3-injection); the plugin sends `Service-Worker-Allowed` itself), which match the probe's attributes, `remote` (the URL of a [remote debugging](#remote-debugging) server to connect pages to, alongside the element), with `idInQuery` (`data-id-in-query`, for a server with `idInQuery`). - -### The element - -A floating F12 button opens the panel; drag its bar to resize. Left of DevTools' settings button, the lock button switches shell mode on and off (a dialog explains it first, and an info bar shows while it is on) and the refresh button reloads the page; the toolbar's × closes the panel. DevTools' main menu (⋮) has "Dock side": bottom (the default), right, left, or undocked, a window over the page moved by its bar and resized from its corner. In shell mode the page shrinks to make room, as in Chrome; otherwise the panel covers the page. The side and sizes are remembered in localStorage, sizes as percentages of the viewport (`dvh`/`dvw`), so they fit any screen and orientation. Its one attribute, `devtools-frontend-url`, is optional: without it, the probe's `data-devtools-frontend-url` is used. The rest is the library's: the probe's options set the element up, and the user opens and closes it; it has no methods of its own. The page can restyle the F12 button with `devtools-anywhere::part(toggler)`, in shell mode too: there the button is in the page iframe, in a `` under the page's `` that only holds it and opens and closes the shell's DevTools (the shell's own button shows only while the page in the iframe has no probe, such as another origin's page). The probe's element does not expose its iframe as a part. Keep the element in one place, since moving it reloads the iframe. - -A page can also place `` itself, as a box where DevTools shows: a block in the page's flow (`display: block`), sized by the page's CSS, with DevTools loaded as soon as it is connected. The page shows and hides it with CSS or `hidden`; hidden, DevTools stays loaded. It has no F12 button, dock side or resizing, and never enters shell mode (DevTools offers no way to); inside the shell's page it works as on its own. `devtools-frontend-url` is its only attribute; others are ignored. DevTools' iframe in it can be styled with `devtools-anywhere::part(iframe)`. - -Any element can open and close the panel: give it `data-devtools-anywhere-toggle`, empty for the probe's element. Set to the id of an element the page placed, a click only fires `devtools-toggle-request` on that element, for the page to act on; such togglers get no `aria-expanded`. Clicks are caught on the document, so togglers added later work too; the default action is not prevented, and the togglers' `aria-expanded` follows the panel. A toggler whose element is missing logs a warning, once per value. Whether the panel is open shows only there, in the togglers' `aria-expanded`: the element fires no event of its own. +**Any page**, from a CDN, first in ``: ```html - + ``` -Togglers work once the probe has loaded `devtools-anywhere.iife.js`, which registers the element, and not inside a shadow root. In shell mode, the togglers of the page inside the shell control the shell's panel, whatever their value. They do nothing in a page of another origin than the shell, such as a cross-origin iframe. +Open the page and tap the **F12** button. -### Remote debugging - -To inspect a page on a phone from Chrome on a computer, start the remote debugging server on the computer: +**A page on a phone, from a computer**: ```bash -npx -p @devtools-anywhere/remote-server devtools-anywhere-remote --port 8080 +npx -p @devtools-anywhere/remote-server devtools-anywhere-remote ``` -It prints the target list's URL, with a token, and the script tag for pages, naming the computer by its LAN address (`--public-url` names it otherwise, for example behind a proxy). Load the probe from the server, with `data-remote`, as the first script in the page's ``: +Add the printed ` -``` +Self-hosting, every option and the element: see the [usage guide](docs/usage.md). -The page connects after it loads (early output is captured as usual), and shows up in the target list, which can be filtered and shows how many DevTools windows are attached; "inspect" opens DevTools on it in a tab of its own. DevTools stays connected when the page reloads or navigates within the tab, and the page reconnects by itself when the connection drops (it stays on the list meanwhile). Several DevTools windows can inspect one page at once, each in a session of its own, as in Chrome: the page runs a CDP session per DevTools session. `data-remote` can also name the server (`data-remote="http://host:8080/"`) when the probe comes from elsewhere. It is resolved against the probe's URL like a link, so a server under a path needs the trailing slash: `/devtools/` names the server at `/devtools/`, while `/devtools` names the one at `/`. With `data-devtools-frontend-url` as well, the page also gets its own DevTools panel; both inspect it at once, each in a session of its own. Shell mode works as without `data-remote` (it needs the probe, bundle and frontend on one origin, so not with the probe from the server and a frontend elsewhere): the page in the shell connects, the shell itself does not. With `data-service-worker` too, so do the same-origin pages without the probe that the page iframe navigates to: the Service Worker gives them the probe, and they connect to the same server. Whether a page in the shell connects, with a probe of its own or one the Service Worker gave it, is up to the shell's probe: without a server there, none connects, and the page's own `data-remote` has no effect. The shell keeps that server in memory, not in `sessionStorage` with the rest of its config, and tells each page in the shell once it has loaded, so a script of the page cannot point the next pages elsewhere. +## Common options -- The server serves the probe, its bundle and the DevTools frontend; the frontend runs on the computer, so the same-origin caveat does not apply. -- Inspecting a page means running any script in it: the target list and DevTools connections need the token (`--token `, by default a random one; `--no-token` turns it off). Pages connect without it. -- An HTTPS page can only connect to an HTTPS server: `--cert` and `--key`. Other options: `--host`, `--base-path`, `--id-in-query` (see `idInQuery` below), `--devtools-frontend-path`, and `--devtools-frontend-url` to open a frontend hosted elsewhere (a CDN; an HTTPS one needs the server over HTTPS too; its links carry `ws=`/`wss=` and the token in the URL fragment, so they are not sent to the CDN). The same is available from Node.js as `startRemoteServer(options)`, and `createRemoteHandler(options)` gives its request and upgrade handlers, to mount under a base path in a server of one's own (Express, Koa, …): each returns false for what is not its own. Pages connect to `target/` and DevTools to `client/`; with `idInQuery: true`, for a host that routes WebSocket upgrades by exact path only, both connect to the base path itself (`?target=`, `?client=`): the target list and the printed script tag use that form, whose script tag carries `data-id-in-query`, and register the base path, with its trailing slash, for upgrades. Both forms are always accepted. A frontend opened by itself connects to any target as `anywhere_app.html?ws=` (`wss=` over HTTPS; also read from the fragment, `#?ws=`), the WebSocket URL without its scheme, as in Chrome's `devtools://devtools/bundled/devtools_app.html?ws=`. -- `/json/list` (with the token) lists the targets in the shape of Chrome's. +| ` - ... - -``` +## 快速上手 -也可以直接用 CDN:jsDelivr 和 unpkg 会提供所有 npm 包,不用自己部署。请固定到确切的版本号,两个包用同一个版本。这种方式只有英文和中文界面,因为其他语言在单独的包里,必须和前端放在同一目录下提供。外壳模式也用不了,因为它要求探针、bundle 和前端都与页面同源(见[外壳模式](#2-外壳模式原地改写)):用 `data-default-shell-mode="off"` 把 devtools 直接嵌在页面里。 +**Vite 项目**,只在 `vite serve` 时生效: -```html - - - - +```bash +npm i -D @devtools-anywhere/vite-plugin ``` -页面加载完成后,探针会从同目录(或 `data-bundle` 指定的地址)加载 `devtools-anywhere.iife.js`,并在 `` 下、与 `` 同级挂载它自己的 ``,不管页面里有没有 ``;每个页面只有一个这样的全局元素,再出现的只在控制台警告。可选属性:`data-open-immediately` 立即打开 devtools;`data-default-shell-mode="off"` 不进入外壳模式(顶层页面默认 `on`,即进入);`data-shell-mode-locked` 锁定外壳模式,始终按 `data-default-shell-mode` 执行,忽略用户的选择(已存的选择保留)并隐藏 DevTools 里的开关,但页面禁止被嵌入 iframe 时仍会退回普通模式;`data-no-toggler` 隐藏 F12 按钮,外壳模式下同样生效;`data-service-worker` 在外壳模式下启用 Service Worker,它给页面 iframe 中没有引入探针的同源页面注入探针,并为同源的文档、样式表、脚本、图片等请求补上请求头和响应头;默认关闭,使用条件见[注入方式](#3-注入方式)。 - -### Vite - ```js // vite.config.js import devtoolsAnywhere from '@devtools-anywhere/vite-plugin' -export default { - plugins: [devtoolsAnywhere()], -} +export default { plugins: [devtoolsAnywhere()] } ``` -只影响 `vite serve`:探针作为第一个脚本注入每个页面,bundle、Service Worker 和前端都在 `/@devtools-anywhere/` 下提供。前端默认来自 `@devtools-anywhere/devtools-frontend`。选项:`devtoolsFrontendPath`(本地另一份前端构建的入口文件路径,如 `…/anywhere_app.html`,托管它所在的目录)、`devtoolsFrontendUrl`(托管在别处的前端,设置后不再托管本地文件)、`openImmediately`、`defaultShellMode`(`'on'` 或 `'off'`)、`shellModeLocked`、`noToggler`(隐藏 F12 按钮)、`serviceWorker`(使用条件见[注入方式](#3-注入方式),`Service-Worker-Allowed` 头由插件返回),与探针的属性一一对应;`remote`([远程调试](#远程调试)服务端的地址,页面同时连接到它,组件照常挂载),以及配合它的 `idInQuery`(`data-id-in-query`,对应服务端的 `idInQuery`)。 +**任意页面**,用 CDN,放在 `` 里第一个: -### 远程调试 - -要在电脑上的 Chrome 里调试手机上的页面,先在电脑上启动远程调试服务端: - -```bash -npx -p @devtools-anywhere/remote-server devtools-anywhere-remote --port 8080 +```html + ``` -它会打印目标列表的地址(带 token)和给页面用的 script 标签,标签里用的是电脑的局域网地址(`--public-url` 可以改成别的地址,例如经过代理时)。从服务端加载探针,带上 `data-remote`,放在页面 `` 的第一个脚本位置: +打开页面,点 **F12** 按钮。 -```html - +**在电脑上调试手机里的页面**: + +```bash +npx -p @devtools-anywhere/remote-server devtools-anywhere-remote ``` -页面加载完成后连接到服务端(早期输出照常捕获),并出现在目标列表中;列表可以筛选,也会显示连着几个 DevTools。点“inspect”会在新标签页里打开它的 DevTools。页面在同一个标签页里刷新或跳转时,DevTools 保持连接;连接断开后页面会自动重连,期间它仍留在列表中。一个页面可以同时被多个 DevTools 调试,和 Chrome 一样各有独立的会话:页面为每个会话运行一个 CDP 会话。探针从别处加载时,`data-remote` 也可以写服务端地址(`data-remote="http://host:8080/"`)。它像链接一样相对探针的地址解析,所以挂在路径下的服务端要带结尾斜杠:`/devtools/` 指 `/devtools/` 下的服务端,`/devtools` 指的却是 `/` 下的。再加上 `data-devtools-frontend-url` 时,页面同时还有自己的 DevTools 面板,两边可以同时调试,各自有独立的会话。外壳模式与不带 `data-remote` 时一样(它要求探针、bundle 和前端同源,所以探针从服务端加载、前端在别处时不会进入):连接服务端的是外壳里的页面,外壳本身不连。再加上 `data-service-worker` 时,页面 iframe 跳转到的没有引入探针的同源页面也会连接:Service Worker 给它们注入探针,它们连接同一个服务端。外壳里的页面(自带探针或由 Service Worker 注入)连不连远程由外壳的探针决定:外壳的探针没有配服务端时都不连,页面自己的 `data-remote` 不起作用。这个服务端地址由外壳保存在内存里,不和其余外壳配置一起写进 `sessionStorage`,外壳里的页面加载完成后再由外壳告诉它,所以页面脚本无法让之后的页面连到别处。 +把打印出的 ` +``` + +From jsDelivr or unpkg, pin an exact version: the probe loads the frontend of that same version from there. + +```html + +``` + +| | Self-hosted | CDN (jsDelivr, unpkg) | +|---|---|---| +| `data-devtools-frontend-url` | Required | Optional: the frontend of the same version on the CDN | +| Shell mode | ✅ On by default | ✅ On by default, without the [Service Worker](#service-worker) | +| UI languages | All, with `@devtools-anywhere/devtools-frontend-locales` in the frontend's directory | English and Chinese | + +- After the page loads, the probe mounts one `` under ``, whatever the page has; a second probe only warns. +- Caching: files under the frontend's `bundle/` are named after their content (cache for good); serve `anywhere_app.html` with `Cache-Control: no-cache`. + +### Attributes + +| Attribute | Vite option | Default | Effect | +|---|---|---|---| +| `data-devtools-frontend-url` | `devtoolsFrontendUrl` | Script tag from jsDelivr or unpkg: the frontend of the same version there. Other script tags: none. Vite: the installed frontend | The frontend's `anywhere_app.html`. Without one, no panel is mounted | +| `data-no-panel` | — | Off | No panel and no shell mode, whatever the frontend: for a page only [debugged remotely](#remote-debugging) | +| `data-bundle` | — | Next to the probe | URL of `devtools-anywhere.iife.js` | +| `data-open-immediately` | `openImmediately` | Off | Opens DevTools as the page loads | +| `data-default-shell-mode` | `defaultShellMode` | `on` | `off`: no shell mode, the panel covers the page | +| `data-shell-mode-locked` | `shellModeLocked` | Off | Follows `data-default-shell-mode`, ignoring the user's choice (kept for later) and hiding DevTools' switch | +| `data-no-toggler` | `noToggler` | Off | Hides the F12 button | +| `data-service-worker` | `serviceWorker` (on / off) | Off | Turns the [Service Worker](#service-worker) on. As an attribute, its URL (empty: next to the bundle) | +| `data-remote` | `remote` | Off | Connects to a [remote debugging](#remote-debugging) server: its URL (empty: where the probe is served from) | +| `data-id-in-query` | `idInQuery` | Off | For a server with `idInQuery` | + +## Vite + +```bash +npm i -D @devtools-anywhere/vite-plugin +``` + +```js +// vite.config.js +import devtoolsAnywhere from '@devtools-anywhere/vite-plugin' + +export default { plugins: [devtoolsAnywhere()] } +``` + +- `vite serve` only. Injects the probe as the first script of every page; serves the bundle, Service Worker and frontend under `/@devtools-anywhere/`. +- Options: those of the [attributes](#attributes), plus `devtoolsFrontendPath` (another frontend build on disk, `…/anywhere_app.html`; its directory is served). +- `serviceWorker: true`: the plugin also sends `Service-Worker-Allowed`. +- With `@devtools-anywhere/devtools-frontend-locales` installed, all UI languages are served. + +## Element placed by the page + +```html + +``` + +- A block in the page's flow, sized by the page's CSS. DevTools loads once it is connected, and stays loaded while `hidden`. +- No F12 button, dock side, resizing or shell mode; inside the shell's page it works as on its own. +- One attribute, `devtools-frontend-url` (default: the probe's); others are ignored. +- Style DevTools' iframe with `devtools-anywhere::part(iframe)`. + +## The panel + +| Control | Effect | +|---|---| +| F12 button | Opens the panel | +| Grip on the middle of the panel's inner edge | Resizes the docked panel (in shell mode too, next to DevTools' own resizer) | +| ⋮ → Dock side | Bottom (default), right, left, or undocked: a window moved by its bar, resized from its corner | +| Lock button (left of ⚙) | Shell mode on / off; a dialog explains it first, an info bar shows while it is on | +| Refresh button | Reloads the page | +| × | Closes the panel | + +- Dock side and sizes are kept in `localStorage`, sizes as percentages of the viewport (`dvh`/`dvw`), so they fit any screen and orientation. +- In shell mode the page shrinks to make room; otherwise the panel covers it. +- Restyle the F12 button with `devtools-anywhere::part(toggler)`, in shell mode too: there it is in the page iframe (the shell shows its own only for a page without the probe, such as another origin's). +- Moving the element in the DOM reloads DevTools. + +### Togglers + +Any element can open and close the panel: + +```html + +``` + +| `data-devtools-anywhere-toggle` | On click | +|---|---| +| Empty | Toggles the probe's panel; `aria-expanded` follows it | +| Id of an element the page placed | Fires `devtools-toggle-request` on that element; no `aria-expanded` | + +- Clicks are caught on the document, so togglers added later work. The default action is not prevented. +- They work once the probe has loaded `devtools-anywhere.iife.js`. +- In the shell's page, every toggler controls the shell's panel. +- Togglers inside a shadow root, or in a page of another origin than the shell, do nothing. +- A toggler naming a missing element warns, once per value. +- The element fires no open / close event: `aria-expanded` on the togglers is the state. + +## Shell mode + +The top-level page is replaced by a shell: the page runs in an iframe next to DevTools, as in Chrome. + +| | Shell mode | Without | +|---|---|---| +| DevTools across reloads and navigations | Stays open, logs kept | Reopens with the page | +| Device mode | ✅ | Screencast instead | +| Layout | The page shrinks next to DevTools | The panel covers the page | + +On by default for the top-level page, when the bundle and frontend (and Service Worker) are where the probe trusts: on its origin, or from jsDelivr or unpkg, in the probe's package and the frontend's at the same version. It is off when: + +| Condition | | +|---|---| +| `data-default-shell-mode="off"` | Unless the user turned it on | +| The user turned it off (lock button) | Unless `data-shell-mode-locked` | +| The page cannot be framed (`X-Frame-Options`, CSP `frame-ancestors`) | For the rest of the session | +| The page keeps navigating the shell away | For the rest of the session | + +Caveats: + +- In an iframe, `100vh`, `position: fixed`, `window.top` and the viewport may behave differently. +- The page's HTML is requested twice; a page returned for a form POST is requested again with GET. + +### Service Worker + +`data-service-worker` / `serviceWorker`, in shell mode: + +- Injects the probe into same-origin pages in the page iframe that lack it. +- Adds request and response headers to same-origin requests (documents, style sheets, scripts, images, …). +- Needs a secure context (HTTPS or localhost), a scope covering the pages (`Service-Worker-Allowed: /`), and no Service Worker of the site's own. + +## Remote debugging + +```bash +npx -p @devtools-anywhere/remote-server devtools-anywhere-remote +``` + +1. Add the printed ` + ``` + +2. Open the printed target list (it has the token) on the computer, and click "inspect". + +The server serves the probe, bundle and frontend itself, and the frontend runs on the computer: the same-origin rules above do not apply. + +### CLI options + +| Option | Default | Effect | +|---|---|---| +| `--port`, `-p` | `8080` | Port | +| `--host` | All interfaces | Interface to listen on | +| `--public-url` | LAN address | URL printed for pages, e.g. behind a proxy | +| `--base-path` | `/` | Serve under a path | +| `--token ` / `--no-token` | Random | Token for the target list and DevTools; pages need none | +| `--cert`, `--key` | — | HTTPS; needed for HTTPS pages | +| `--id-in-query` | Off | Target in the query, for hosts that route WebSocket upgrades by exact path | +| `--devtools-frontend-path` | The bundled frontend | Another frontend build on disk | +| `--devtools-frontend-url` | — | A frontend hosted elsewhere (a CDN; over HTTPS, the server needs HTTPS too). The WebSocket and token go in the URL fragment, not sent to the CDN | + +### Behavior + +- Pages connect after load; early output is captured as usual. +- DevTools stays connected across reloads and navigations in the tab. A page whose connection drops reconnects by itself, and stays on the list meanwhile. +- Several DevTools windows can inspect one page, each in its own session. +- The target list can be filtered and shows how many DevTools each page has. +- With a frontend too (`data-devtools-frontend-url`, or the probe from jsDelivr or unpkg), the page also has its own panel; `data-no-panel` goes without it. +- Breakpoints still need the browser's own debugger (`chrome://inspect`, Safari Web Inspector): the page runs the same in-page agent. + +`data-remote` with a value names the server, when the probe comes from elsewhere. It resolves like a link: + +| `data-remote` | Server | +|---|---| +| `http://host:8080/devtools/` | At `/devtools/` | +| `http://host:8080/devtools` | At `/` | + +In shell mode: + +- The page in the shell connects, not the shell. +- The shell's probe decides for every page in the shell; their own `data-remote` is ignored, so a page's scripts cannot send later pages elsewhere. +- With `data-service-worker`, pages it injects connect too. +- Shell mode needs the frontend where the probe trusts it: not with the probe from the server and a frontend elsewhere. + +### Node.js and endpoints + +| API / URL | Description | +|---|---| +| `startRemoteServer(options)` | The CLI's server | +| `createRemoteHandler(options)` | Request and upgrade handlers for your own server (Express, Koa, …); `false` for requests not theirs | +| `target/` | The page's WebSocket | +| `client/` | DevTools' WebSocket | +| `?target=`, `?client=` | The same, with `idInQuery` (both forms always accepted) | +| `/json/list` | Targets, in Chrome's format (needs the token) | +| `anywhere_app.html?ws=` | A frontend opened by itself connects to a target, as Chrome's `devtools_app.html?ws=` (`wss=` over HTTPS; also `#?ws=`) | + +## Limitations + +Gaps are filled exactly or not at all: nothing approximate is shown as fact. + +| Feature | Status | Details | +|---|---|---| +| Breakpoints, Profiler, Memory, Performance | ❌ Panels not included | No JS API. `debugger;` does not pause; in shell mode it logs a warning with its stack | +| Network timing | ❌ | No JS API | +| Sources | ⚠️ | Documents, external scripts and style sheets, images, fonts; no inline scripts; snippets work | +| Browser default styles | ❌ | Not exposed to pages | +| Event listeners | ⚠️ | Only those added after the probe ran; no source location | +| WebSocket | ⚠️ | Connections and frames; no handshake headers | +| Cross-origin style sheets | ⚠️ | Need CORS; read-only | +| Forced `:hover`, `:focus`, … | ⚠️ | Not for `@import` and `adoptedStyleSheets` sheets | +| Screencast (device button outside shell mode) | ⚠️ | Redrawn by [snapDOM](https://github.com/zumerlab/snapdom) from the DOM: canvas, video and cross-origin images may be missing; ≤ 2 fps. Input replayed with `isTrusted: false` | +| Screenshots | ⚠️ | See [below](#screenshots) | +| Device mode | ⚠️ | Shell mode only. Sets the size only: no pixel ratio, user agent or touch; its screenshots are snapDOM's, at the page's pixel ratio | +| Fullscreen pages | ⚠️ | While an element is fullscreen, DevTools cannot be used, in shell mode too. Esc leaves fullscreen | +| Frontend on the page's origin | ⚠️ | DevTools' settings share the page's `localStorage`: host it on another origin | +| Browsers | ⚠️ | Page side: any modern browser. DevTools UI: verified in Chrome only; newer frontends may need compatibility patches | + +snapDOM (about 90 KB gzip) loads only for the screencast and screenshots. + +### Screenshots + +`Page.captureScreenshot`, for tools and agents that drive the page over CDP: + +- Rendered by snapDOM like the screencast, and marked `approximate: true` (a field Chrome does not send). +- `clip` is in page coordinates; the image is in device pixels (`devicePixelRatio` × `clip.scale`). +- `captureBeyondViewport` without `clip` takes the whole page. +- Past the canvas limits (32767 px a side, 16384 on Safari, 16384 × 16384 in area) the image is scaled down; snapDOM warns in the console. +- WebP falls back to PNG where it cannot be encoded (Safari). + +## Packages + +| Package | Contents | +|---|---| +| `devtools-anywhere` | `probe.iife.js`, `devtools-anywhere.iife.js` (the element), `devtools-anywhere-service-worker.js`, `snapdom.js`; TypeScript types for the element, its events and the attributes. No module to import | +| `@devtools-anywhere/devtools-frontend` | The frontend, English and Chinese UI | +| `@devtools-anywhere/devtools-frontend-locales` | The other 80 UI languages; serve from the frontend's directory. Missing languages fall back to English | +| `@devtools-anywhere/vite-plugin` | Vite plugin | +| `@devtools-anywhere/remote-server` | Remote debugging server and CLI (`devtools-anywhere-remote`) | diff --git a/docs/usage.zh-CN.md b/docs/usage.zh-CN.md new file mode 100644 index 0000000..ecfb15e --- /dev/null +++ b/docs/usage.zh-CN.md @@ -0,0 +1,263 @@ +# 使用指南 + +[English](usage.md) | 简体中文 + +devtools-anywhere 的接入参考。快速上手见 [README](../README.zh-CN.md),工作原理见[设计文档](design.zh-CN.md)。 + +## 选择接入方式 + +| 场景 | 接入方式 | +|---|---| +| 任意页面,自己部署,要外壳模式 | [script 标签](#script-标签) | +| 任意页面,不想部署 | 从 CDN 引入的 [script 标签](#script-标签) | +| Vite 项目,开发时用 | [Vite 插件](#vite) | +| 在电脑上调试手机里的页面 | [远程调试](#远程调试) | +| DevTools 嵌在页面自己的布局里 | [页面自己放置的组件](#页面自己放置的组件) | + +## script 标签 + +```bash +npm i devtools-anywhere @devtools-anywhere/devtools-frontend +``` + +部署这两个包的 `dist/`,把探针放在 `` 里第一个,同步加载: + +```html + +``` + +用 jsDelivr 或 unpkg 时,固定一个确切版本:探针会从同一个 CDN 加载同版本的前端。 + +```html + +``` + +| | 自己部署 | CDN(jsDelivr、unpkg) | +|---|---|---| +| `data-devtools-frontend-url` | 必填 | 可选:默认用 CDN 上同版本的前端 | +| 外壳模式 | ✅ 默认开启 | ✅ 默认开启,但没有 [Service Worker](#service-worker) | +| 界面语言 | 全部(`@devtools-anywhere/devtools-frontend-locales` 放在前端目录下) | 英文和中文 | + +- 页面加载完成后,探针在 `` 下挂载一个 ``,不管页面里有没有;第二个探针只会警告。 +- 缓存:前端 `bundle/` 下的文件以内容命名,可以永久缓存;`anywhere_app.html` 用 `Cache-Control: no-cache` 提供。 + +### 属性 + +| 属性 | Vite 选项 | 默认值 | 作用 | +|---|---|---|---| +| `data-devtools-frontend-url` | `devtoolsFrontendUrl` | 来自 jsDelivr、unpkg 的 script 标签:CDN 上同版本的前端。其他 script 标签:无。Vite:已安装的前端 | 前端的 `anywhere_app.html`。没有前端就不挂载面板 | +| `data-no-panel` | — | 关 | 不管有没有前端,都不挂载面板、不进外壳模式:用于只做[远程调试](#远程调试)的页面 | +| `data-bundle` | — | 探针同目录 | `devtools-anywhere.iife.js` 的地址 | +| `data-open-immediately` | `openImmediately` | 关 | 页面加载后直接打开 DevTools | +| `data-default-shell-mode` | `defaultShellMode` | `on` | `off`:不用外壳模式,面板盖在页面上 | +| `data-shell-mode-locked` | `shellModeLocked` | 关 | 始终按 `data-default-shell-mode` 执行,忽略用户的选择(选择仍会保留),并隐藏 DevTools 里的开关 | +| `data-no-toggler` | `noToggler` | 关 | 隐藏 F12 按钮 | +| `data-service-worker` | `serviceWorker`(开 / 关) | 关 | 启用 [Service Worker](#service-worker)。作为属性时可以写它的地址(留空:bundle 同目录) | +| `data-remote` | `remote` | 关 | 连接[远程调试](#远程调试)服务端:它的地址(留空:探针所在的服务端) | +| `data-id-in-query` | `idInQuery` | 关 | 配合开启了 `idInQuery` 的服务端 | + +## Vite + +```bash +npm i -D @devtools-anywhere/vite-plugin +``` + +```js +// vite.config.js +import devtoolsAnywhere from '@devtools-anywhere/vite-plugin' + +export default { plugins: [devtoolsAnywhere()] } +``` + +- 只在 `vite serve` 时生效。探针作为每个页面的第一个脚本注入;bundle、Service Worker 和前端都在 `/@devtools-anywhere/` 下提供。 +- 选项:和[属性](#属性)一一对应,另加 `devtoolsFrontendPath`(本地另一份前端构建,`…/anywhere_app.html`,托管它所在的目录)。 +- `serviceWorker: true`:插件还会返回 `Service-Worker-Allowed` 头。 +- 装了 `@devtools-anywhere/devtools-frontend-locales` 就提供全部界面语言。 + +## 页面自己放置的组件 + +```html + +``` + +- 页面布局中的一个块,尺寸由页面 CSS 决定。连接到文档后就加载 DevTools,`hidden` 时保持加载。 +- 没有 F12 按钮、停靠位置、调整大小和外壳模式;在外壳里的页面中照常工作。 +- 只有一个属性 `devtools-frontend-url`(默认用探针的),其他属性一律忽略。 +- DevTools 的 iframe 可以用 `devtools-anywhere::part(iframe)` 改样式。 + +## 面板 + +| 控件 | 作用 | +|---|---| +| F12 按钮 | 打开面板 | +| 面板内侧边缘正中的抓手 | 调整停靠面板的大小(外壳模式下也有,旁边还有 DevTools 自己的分隔条) | +| ⋮ → 停靠位置 | 底部(默认)、右侧、左侧,或取消停靠:拖横条移动、拖右下角调整大小的浮动窗口 | +| 锁形按钮(⚙ 左边) | 开关外壳模式;开启前有对话框说明,开启期间顶部有提示条 | +| 刷新按钮 | 重新加载页面 | +| × | 关闭面板 | + +- 停靠位置和尺寸记在 `localStorage` 里,尺寸按视口百分比记录(`dvh`/`dvw`),换屏幕或横竖屏后依然合适。 +- 外壳模式下页面会让出空间;否则面板盖在页面上。 +- 可以用 `devtools-anywhere::part(toggler)` 修改 F12 按钮的样式,外壳模式下同样有效:此时按钮在页面 iframe 里(只有页面没有探针时,例如其他源的页面,外壳才显示自己的按钮)。 +- 在 DOM 里移动组件会重新加载 DevTools。 + +### 切换按钮 + +任何元素都可以开关面板: + +```html + +``` + +| `data-devtools-anywhere-toggle` | 点击时 | +|---|---| +| 空 | 开关探针的面板;`aria-expanded` 跟随面板状态 | +| 页面放置的组件的 id | 在那个组件上触发 `devtools-toggle-request`;没有 `aria-expanded` | + +- 在 document 上监听点击,之后加入的按钮也有效。不阻止默认行为。 +- 探针加载完 `devtools-anywhere.iife.js` 后才生效。 +- 外壳里的页面上,所有切换按钮都控制外壳的面板。 +- 位于 shadow root 里、或与外壳不同源的页面里的切换按钮无效。 +- 指向不存在的组件时会警告,每个值只警告一次。 +- 组件不触发开关事件:切换按钮上的 `aria-expanded` 就是状态。 + +## 外壳模式 + +顶层页面换成一个外壳:页面运行在 DevTools 旁边的 iframe 里,和 Chrome 一样。 + +| | 外壳模式 | 非外壳模式 | +|---|---|---| +| 刷新、跳转时的 DevTools | 保持打开,日志保留 | 随页面重新打开 | +| 设备模式 | ✅ | 换成投屏 | +| 布局 | 页面在 DevTools 旁边缩小 | 面板盖在页面上 | + +bundle、前端(以及 Service Worker)在探针信任的位置时,顶层页面默认开启外壳模式。信任的位置是:与探针同源;探针来自 jsDelivr 或 unpkg 时,则只限探针所在的包和同版本的前端包。以下情况关闭: + +| 情况 | | +|---|---| +| `data-default-shell-mode="off"` | 用户手动开启时除外 | +| 用户点锁形按钮关闭 | 设置了 `data-shell-mode-locked` 时除外 | +| 页面禁止被嵌入(`X-Frame-Options`、CSP `frame-ancestors`) | 本次会话内一直关闭 | +| 页面反复把外壳跳转走 | 本次会话内一直关闭 | + +注意: + +- 在 iframe 里,`100vh`、`position: fixed`、`window.top` 和视口的表现可能不同。 +- 页面的 HTML 会请求两次;表单 POST 返回的页面会用 GET 再请求一次。 + +### Service Worker + +`data-service-worker` / `serviceWorker`,外壳模式下: + +- 给页面 iframe 里没有探针的同源页面注入探针。 +- 为同源请求(文档、样式表、脚本、图片等)补上请求头和响应头。 +- 需要安全上下文(HTTPS 或 localhost)、覆盖这些页面的作用域(`Service-Worker-Allowed: /`),且站点没有自己的 Service Worker。 + +## 远程调试 + +```bash +npx -p @devtools-anywhere/remote-server devtools-anywhere-remote +``` + +1. 把它打印出的 ` + ``` + +2. 在电脑上打开它打印出的目标列表(地址里带令牌),点「inspect」。 + +服务端自己提供探针、bundle 和前端,前端运行在电脑上:上面的同源要求不适用。 + +### 命令行选项 + +| 选项 | 默认值 | 作用 | +|---|---|---| +| `--port`、`-p` | `8080` | 端口 | +| `--host` | 所有网卡 | 监听的网卡 | +| `--public-url` | 局域网地址 | 打印给页面的地址,例如在代理后面时 | +| `--base-path` | `/` | 挂在某个路径下 | +| `--token ` / `--no-token` | 随机 | 目标列表和 DevTools 需要的令牌;页面不需要 | +| `--cert`、`--key` | — | HTTPS;HTTPS 页面需要 | +| `--id-in-query` | 关 | 目标放在查询参数里,用于按完整路径转发 WebSocket 升级的主机 | +| `--devtools-frontend-path` | 自带的前端 | 本地另一份前端构建 | +| `--devtools-frontend-url` | — | 托管在别处的前端(CDN;用 HTTPS 时服务端也要 HTTPS)。WebSocket 地址和令牌放在 URL 片段里,不会发给 CDN | + +### 行为 + +- 页面加载完成后连接;早期输出照常捕获。 +- 页面刷新或在同一标签页内跳转时,DevTools 保持连接。断线的页面会自动重连,期间仍留在列表里。 +- 多个 DevTools 窗口可以同时调试一个页面,各自一个会话。 +- 目标列表可以筛选,并显示每个页面连着几个 DevTools。 +- 同时有前端时(写了 `data-devtools-frontend-url`,或探针来自 jsDelivr、unpkg),页面还有自己的面板;不要面板时加 `data-no-panel`。 +- 断点仍要靠浏览器自带的调试器(`chrome://inspect`、Safari Web Inspector):页面里运行的仍是同一个页面内 agent。 + +探针来自别处时,用带值的 `data-remote` 指定服务端,按链接的规则解析: + +| `data-remote` | 服务端 | +|---|---| +| `http://host:8080/devtools/` | 在 `/devtools/` 下 | +| `http://host:8080/devtools` | 在 `/` 下 | + +外壳模式下: + +- 连接的是外壳里的页面,不是外壳本身。 +- 外壳里的所有页面由外壳的探针决定,页面自己的 `data-remote` 无效,所以页面脚本没法把之后的页面指向别的服务端。 +- 配合 `data-service-worker`,被它注入的页面也会连接。 +- 外壳模式要求前端在探针信任的位置:探针来自服务端、前端在别处时用不了。 + +### Node.js 与接口 + +| API / 地址 | 说明 | +|---|---| +| `startRemoteServer(options)` | 命令行用的服务端 | +| `createRemoteHandler(options)` | 请求和升级处理函数,挂到自己的服务里(Express、Koa 等);不属于它的请求返回 `false` | +| `target/` | 页面的 WebSocket | +| `client/` | DevTools 的 WebSocket | +| `?target=`、`?client=` | 同上,`idInQuery` 时的形式(两种都始终接受) | +| `/json/list` | 目标列表,格式同 Chrome(需要令牌) | +| `anywhere_app.html?ws=` | 单独打开的前端连接某个目标,同 Chrome 的 `devtools_app.html?ws=`(HTTPS 用 `wss=`;也可写在 `#?ws=`) | + +## 能力边界 + +拿不到的数据要么精确补上,要么不显示:不把近似值当成事实。 + +| 功能 | 状态 | 说明 | +|---|---|---| +| 断点、Profiler、内存、性能 | ❌ 不打包这些面板 | 没有 JS API。`debugger;` 不会暂停;外壳模式下改为输出一条带堆栈的警告 | +| 网络时序 | ❌ | 没有 JS API | +| 源代码 | ⚠️ | 文档、外链脚本和样式表、图片、字体;不列内联脚本;代码段可用 | +| 浏览器默认样式 | ❌ | 浏览器不向页面公开 | +| 事件监听器 | ⚠️ | 只有探针运行之后添加的;没有源码位置 | +| WebSocket | ⚠️ | 连接和帧;没有握手头 | +| 跨源样式表 | ⚠️ | 需要 CORS;只读 | +| 强制 `:hover`、`:focus` 等 | ⚠️ | 不覆盖 `@import` 和 `adoptedStyleSheets` 的样式表 | +| 投屏(非外壳模式下的设备按钮) | ⚠️ | 由 [snapDOM](https://github.com/zumerlab/snapdom) 从 DOM 重绘:canvas、视频、跨源图片可能缺失;每秒最多约 2 帧。输入以 `isTrusted: false` 的事件回放 | +| 截图 | ⚠️ | 见[下文](#截图) | +| 设备模式 | ⚠️ | 仅外壳模式。只改尺寸,不改像素比、UA 和触摸;它的截图由 snapDOM 生成,按页面自身的像素比 | +| 全屏的页面 | ⚠️ | 有元素全屏时用不了 DevTools,外壳模式下也一样。按 Esc 退出全屏即可 | +| 前端与页面同源 | ⚠️ | DevTools 的设置会和页面共用 `localStorage`:前端放在别的源 | +| 浏览器 | ⚠️ | 页面这边:现代浏览器都行。DevTools 界面:只在 Chrome 上验证过;新版前端可能需要兼容补丁 | + +snapDOM(gzip 后约 90 KB)只在投屏和截图时加载。 + +### 截图 + +`Page.captureScreenshot`,供通过 CDP 操作页面的工具和 agent 使用: + +- 和投屏一样由 snapDOM 绘制,并标注 `approximate: true`(Chrome 不返回这个字段)。 +- `clip` 用页面坐标;图片按设备像素计算(`devicePixelRatio` × `clip.scale`)。 +- 没有 `clip` 时,`captureBeyondViewport` 截整个页面。 +- 超出画布上限(每边 32767 像素,Safari 为 16384,面积 16384 × 16384)时缩小,snapDOM 会在控制台警告。 +- 不支持编码 WebP 的浏览器(Safari)退回 PNG。 + +## 包 + +| 包 | 内容 | +|---|---| +| `devtools-anywhere` | `probe.iife.js`、`devtools-anywhere.iife.js`(组件)、`devtools-anywhere-service-worker.js`、`snapdom.js`;组件、事件和各属性的 TypeScript 类型。没有可以 import 的模块 | +| `@devtools-anywhere/devtools-frontend` | 前端,英文和中文界面 | +| `@devtools-anywhere/devtools-frontend-locales` | 其余 80 种界面语言;和前端放在同一目录。缺少的语言退回英文 | +| `@devtools-anywhere/vite-plugin` | Vite 插件 | +| `@devtools-anywhere/remote-server` | 远程调试服务端和命令行(`devtools-anywhere-remote`) | diff --git a/packages/devtools-anywhere/etc/devtools-anywhere.api.md b/packages/devtools-anywhere/etc/devtools-anywhere.api.md index f3ca1ed..97f23fb 100644 --- a/packages/devtools-anywhere/etc/devtools-anywhere.api.md +++ b/packages/devtools-anywhere/etc/devtools-anywhere.api.md @@ -33,6 +33,7 @@ export interface DevtoolsAnywhereTagNameMap { // @public export type ProbeScriptAttributes = { 'data-devtools-frontend-url'?: string; + 'data-no-panel'?: true; 'data-bundle'?: string; 'data-open-immediately'?: true; 'data-default-shell-mode'?: 'on' | 'off'; diff --git a/packages/devtools-anywhere/etc/devtools-anywhere.public.api.md b/packages/devtools-anywhere/etc/devtools-anywhere.public.api.md index cb6f6ce..240bf46 100644 --- a/packages/devtools-anywhere/etc/devtools-anywhere.public.api.md +++ b/packages/devtools-anywhere/etc/devtools-anywhere.public.api.md @@ -33,6 +33,7 @@ export interface DevtoolsAnywhereTagNameMap { // @public export type ProbeScriptAttributes = { 'data-devtools-frontend-url'?: string; + 'data-no-panel'?: true; 'data-bundle'?: string; 'data-open-immediately'?: true; 'data-default-shell-mode'?: 'on' | 'off'; diff --git a/packages/devtools-anywhere/src/cdp-agent/core/objects.ts b/packages/devtools-anywhere/src/cdp-agent/core/objects.ts index be6ce9a..4542254 100644 --- a/packages/devtools-anywhere/src/cdp-agent/core/objects.ts +++ b/packages/devtools-anywhere/src/cdp-agent/core/objects.ts @@ -20,6 +20,8 @@ function toProtocolSubtype(subtype: NonNullable): ProtocolSubtype { const MAX_OBJECT_PREVIEW = 5; const MAX_ARRAY_PREVIEW = 100; const MAX_ENTRY_PREVIEW = 5; +/** console.table's rows, and the columns of each, as V8 previews them. */ +const MAX_TABLE_PREVIEW = 1000; /** An entry of a Map or Set, exposed under the `[[Entries]]` internal property. */ class InternalEntry { @@ -181,6 +183,11 @@ export interface WrapOptions { generatePreview?: boolean | undefined; returnByValue?: boolean | undefined; group?: string | undefined; + /** + * console.table's data: like V8, its preview lists up to 1000 rows, each with a preview of its + * own (one level deep), of only the given columns if any. + */ + table?: { columns?: readonly string[] | undefined } | undefined; } /** Turns page values into CDP RemoteObjects and keeps the objects alive by id. */ @@ -223,7 +230,8 @@ export class ObjectRegistry { if (type === 'function') result.className = 'Function'; else if (type === 'object') result.className = subtype === 'error' ? (value as Error).name || className(value) : className(value); - if (options.generatePreview && type === 'object') result.preview = this.preview(value as object, subtype); + if (options.table && type === 'object') result.preview = this.tablePreview(value as object, subtype, options.table); + else if (options.generatePreview && type === 'object') result.preview = this.preview(value as object, subtype); result.objectId = this.idOf(value, options.group); return result; } @@ -385,7 +393,25 @@ export class ObjectRegistry { return result; } - private preview(obj: object, subtype: Subtype): ObjectPreview { + private tablePreview(obj: object, subtype: Subtype, { columns }: { columns?: readonly string[] | undefined }) { + const preview = this.preview(obj, subtype, MAX_TABLE_PREVIEW); + for (const row of preview.properties) { + if (row.type !== 'object' || row.subtype === 'null') continue; + let value: unknown; + try { + value = Reflect.get(obj, row.name); + } catch { + continue; + } + const { subtype: rowSubtype } = classify(value); + const rowPreview = this.preview(value as object, rowSubtype, MAX_TABLE_PREVIEW); + if (columns) rowPreview.properties = rowPreview.properties.filter(p => columns.includes(p.name)); + row.valuePreview = rowPreview; + } + return preview; + } + + private preview(obj: object, subtype: Subtype, limitOverride?: number): ObjectPreview { const preview: ObjectPreview = { type: 'object', description: describe(obj, subtype), @@ -396,7 +422,7 @@ export class ObjectRegistry { if (subtype === 'internal#entry') return preview; const isArrayLike = subtype === 'array' || subtype === 'typedarray'; - const limit = isArrayLike ? MAX_ARRAY_PREVIEW : MAX_OBJECT_PREVIEW; + const limit = limitOverride ?? (isArrayLike ? MAX_ARRAY_PREVIEW : MAX_OBJECT_PREVIEW); const add = (property: PropertyPreview) => { if (preview.properties.length >= limit) { preview.overflow = true; diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/input/input.conformance.test.ts b/packages/devtools-anywhere/src/cdp-agent/domains/input/input.conformance.test.ts index 6b707f7..3b86c43 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/input/input.conformance.test.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/input/input.conformance.test.ts @@ -166,8 +166,10 @@ test('a keyDown with text (Puppeteer style) fires keypress and types', async () assert.equal(result.value, 'h'); }); +// Where Chrome's hover was after the test before is not always where ours was (on CI, Chrome +// sometimes sends no pointerout for the field it last clicked): only the press is compared. test('a middle click fires auxclick', async () => { - const result = await same(async side => { + const steps = async (side: Side) => { await side.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 50, y: 30 }); await side.send('Input.dispatchMouseEvent', { type: 'mousePressed', @@ -185,8 +187,12 @@ test('a middle click fires auxclick', async () => { buttons: 0, clickCount: 1, }); - }); - assert.ok(result.events.includes('auxclick:btn')); + }; + const pressed = (events: string[]) => events.filter(e => !/^(pointer|mouse)(over|out):/.test(e)); + const chrome = pressed((await run(t.chrome, steps)).events); + const ours = pressed((await run(t.ours, steps)).events); + assert.deepEqual(ours, chrome); + assert.ok(ours.includes('auxclick:btn')); }); // Off the page, Chrome sends the events to the document, the release included, and clicks it @@ -341,11 +347,12 @@ test('a release or a move without a touch sends nothing', async () => { } await side.send('Emulation.setEmitTouchEventsForMouse', { enabled: false }); }; - // Chrome's hover from earlier tests sometimes gets a pointerover and a mouseover through: - // only what a touch would send is compared. - const touchy = (events: string[]) => events.filter(e => !/^(pointer|mouse)over:/.test(e)); + // Chrome's hover from earlier tests sometimes gets a pointerover and a mouseover through, and on + // CI a release sometimes reaches the page as a mouse release (pointerup, mouseup) before touch + // emulation takes it: on Chrome's side only what a touch would send is compared. Ours sends nothing. + const touchy = (events: string[]) => events.filter(e => !/^(pointer|mouse)(over|up):/.test(e)); assert.deepEqual(touchy((await run(t.chrome, steps('chrome'))).events), []); - assert.deepEqual(touchy((await run(t.ours, steps('ours'))).events), []); + assert.deepEqual((await run(t.ours, steps('ours'))).events, []); assert.deepEqual(responses['ours'], responses['chrome']); assert.deepEqual(responses['chrome'], [{}, {}, {}, {}]); }); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.conformance.test.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.conformance.test.ts index 1654152..8eb4699 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.conformance.test.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.conformance.test.ts @@ -152,3 +152,194 @@ test('the screencast scales frames to maxWidth, and encodes them as asked', asyn await s.call('Page.stopScreencast'); } }); + +// Screenshots: ours renders the DOM with snapDOM, so only the size and coarse content of the +// image can match Chrome's. A page of its own: it is made long, scrolled and emulated. +test('captureScreenshot without snapDOM reports that it is not available', async () => { + await assert.rejects(t.ours.send('Page.captureScreenshot', {}), /snapDOM is not configured/); +}); + +function isScreenshot(r: unknown): r is { data: string; approximate?: unknown } { + return typeof r === 'object' && r !== null && 'data' in r && typeof r.data === 'string'; +} + +/** A page 2000 × 3000 CSS pixels with a red box at (10, 520), scrolled to (0, 500). */ +async function screenshotPage() { + assert.ok(env); + const s = await openPage(env, []); + await s.page.evaluate(() => { + document.body.innerHTML = ''; + document.body.style.cssText = 'margin:0;width:2000px;height:3000px;background:#fff'; + const box = document.createElement('div'); + box.style.cssText = 'position:absolute;left:10px;top:520px;width:100px;height:50px;background:rgb(255,0,0)'; + document.body.append(box); + scrollTo(0, 500); + }); + const snapdom = env.baseUrl + '/packages/devtools-anywhere/dist/snapdom.js'; + /** Takes a screenshot with ours, with the real snapDOM. */ + const ours = async (params: object) => { + const shot = await s.page.evaluate( + (url, p) => + window.DevtoolsAnywhere.createCdpSession({ loadSnapdom: () => import(url) }).call('Page.captureScreenshot', p), + snapdom, + params + ); + assert.ok(isScreenshot(shot), JSON.stringify(shot)); + return shot; + }; + const chrome = (params: object) => s.chrome.send('Page.captureScreenshot', params); + /** The size of a base64 image and the color at its center, decoded by the page. */ + const look = (data: string) => + s.page.evaluate(async d => { + const img = new Image(); + img.src = 'data:image;base64,' + d; + await img.decode(); + const canvas = Object.assign(document.createElement('canvas'), { width: img.width, height: img.height }); + const g = canvas.getContext('2d'); + if (!g) throw new Error('no 2d context'); + g.drawImage(img, 0, 0); + const [r, gr, b] = g.getImageData(img.width >> 1, img.height >> 1, 1, 1).data; + return { width: img.width, height: img.height, center: [r, gr, b] }; + }, data); + return { s, ours, chrome, look }; +} + +test('captureScreenshot matches Chrome in size, at the viewport, the whole page and a clip', async () => { + const { s, ours, chrome, look } = await screenshotPage(); + try { + const clip = { x: 10, y: 520, width: 100, height: 50, scale: 1 }; + for (const params of [ + {}, + { captureBeyondViewport: true }, + { clip }, + { clip, captureBeyondViewport: true }, + { clip: { ...clip, scale: 2 } }, + ]) { + const [a, b] = await Promise.all([ours(params), chrome(params)]); + const [mine, theirs] = await Promise.all([look(a.data), look(b.data)]); + assert.equal(mine.width, theirs.width, JSON.stringify(params)); + assert.equal(mine.height, theirs.height, JSON.stringify(params)); + // A clip is in page coordinates, with or without captureBeyondViewport: the red box. + if ('clip' in params) assert.deepEqual(mine.center, theirs.center, JSON.stringify(params)); + } + assert.deepEqual((await look((await ours({ clip })).data)).center, [255, 0, 0]); + } finally { + await s.page.close(); + } +}); + +test('captureScreenshot has device pixels, encodes as asked and says it is approximate', async () => { + const { s, ours, chrome, look } = await screenshotPage(); + try { + await s.chrome.send('Emulation.setDeviceMetricsOverride', { + width: 400, + height: 300, + deviceScaleFactor: 2, + mobile: false, + }); + const [a, b] = await Promise.all([ours({}), chrome({})]); + assert.deepEqual( + [(await look(a.data)).width, (await look(a.data)).height], + [(await look(b.data)).width, (await look(b.data)).height] + ); + assert.equal(a.approximate, true); + assert.ok(a.data.startsWith('iVBOR'), 'PNG by default'); + assert.ok((await ours({ format: 'jpeg', quality: 50 })).data.startsWith('/9j/'), 'JPEG'); + assert.ok((await ours({ format: 'webp' })).data.startsWith('UklGR'), 'WebP'); + } finally { + await s.page.close(); + } +}); + +test('captureScreenshot keeps a long page at full size while the canvas can hold it', async () => { + const { s, ours, look } = await screenshotPage(); + try { + await s.page.evaluate(() => (document.body.style.height = '20000px')); + const { width, height } = await look((await ours({ captureBeyondViewport: true })).data); + assert.deepEqual([width, height], [2000, 20000]); + } finally { + await s.page.close(); + } +}); + +test('captureScreenshot scales a page too large for a canvas down to fit', async () => { + const { s, ours, look } = await screenshotPage(); + try { + // 200000 pixels high, past the longest side a canvas can have (32767 in Chrome). + await s.page.evaluate(() => (document.body.style.height = '200000px')); + const { width, height } = await look((await ours({ captureBeyondViewport: true })).data); + assert.ok(height <= 32767, `${width} × ${height}`); + assert.ok(Math.abs(width / height - 2000 / 200000) < 0.001, `${width} × ${height}`); + } finally { + await s.page.close(); + } +}); + +test('getLayoutMetrics matches Chrome on a scrolled page', async () => { + const { s } = await screenshotPage(); + try { + const [ours, chrome] = await Promise.all([ + s.ours.send('Page.getLayoutMetrics'), + s.chrome.send('Page.getLayoutMetrics'), + ]); + assert.deepEqual(ours, chrome); + } finally { + await s.page.close(); + } +}); + +test('getLayoutMetrics matches Chrome on a screen with a scale factor of 2', async () => { + // The deprecated fields are in device pixels, the css* ones in CSS pixels. + const hiDpi = await launch(['--force-device-scale-factor=2', '--window-size=800,600'], { defaultViewport: null }); + try { + const s = await openPage(hiDpi, []); + await s.page.evaluate(() => { + document.body.style.cssText = 'margin:0;width:1500px;height:3000px'; + scrollTo(30, 500); + }); + assert.equal(await s.page.evaluate(() => devicePixelRatio), 2); + const [ours, chrome] = await Promise.all([ + s.ours.send('Page.getLayoutMetrics'), + s.chrome.send('Page.getLayoutMetrics'), + ]); + assert.deepEqual(ours, chrome); + } finally { + await hiDpi.close(); + } +}); + +test('captureScreenshot draws again at half the size when drawing fails, not when loading does', async () => { + const result = await t.page.evaluate(async () => { + const scales: number[] = []; + let loads = 0; + const drawing = window.DevtoolsAnywhere.createCdpSession({ + loadSnapdom: async () => ({ + snapdom: async () => ({ + async toCanvas({ scale }) { + scales.push(scale); + // Too large to draw until a quarter of the size. + if (scale > 0.25) throw new Error('canvas too large'); + return Object.assign(document.createElement('canvas'), { width: 10, height: 10 }); + }, + }), + }), + }); + await drawing.call('Page.captureScreenshot', { clip: { x: 0, y: 0, width: 20000, height: 20000, scale: 1 } }); + const loading = window.DevtoolsAnywhere.createCdpSession({ + loadSnapdom: () => { + loads++; + return Promise.reject(new Error('offline')); + }, + }); + const error = await loading + .call('Page.captureScreenshot', { clip: { x: 0, y: 0, width: 20000, height: 20000, scale: 1 } }) + .then( + () => '', + (e: unknown) => String(e) + ); + return { scales, loads, error }; + }); + assert.deepEqual(result.scales, [1, 0.5, 0.25]); + assert.equal(result.loads, 1); + assert.match(result.error, /offline/); +}); diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts index 91785f6..8bd8e4d 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/page.ts @@ -63,6 +63,43 @@ export function registerPage({ session, probe }: Context) { return { url: link.href, errors: [], data: await res.text(), manifest: {} }; }, + /** + * The css* fields are in CSS pixels; the deprecated ones are mostly in device pixels, as + * Chrome reports them: times the screen's scale factor, devicePixelRatio as page scripts + * see it. + */ + getLayoutMetrics(): Protocol.Page.GetLayoutMetricsResponse { + const root = document.documentElement; + const layout = { pageX: scrollX, pageY: scrollY, clientWidth: root.clientWidth, clientHeight: root.clientHeight }; + const vv = visualViewport; + const visual = { + offsetX: vv?.offsetLeft ?? 0, + offsetY: vv?.offsetTop ?? 0, + pageX: vv?.pageLeft ?? scrollX, + pageY: vv?.pageTop ?? scrollY, + clientWidth: vv?.width ?? root.clientWidth, + clientHeight: vv?.height ?? root.clientHeight, + scale: vv?.scale ?? 1, + zoom: 1, + }; + const content = { x: 0, y: 0, width: root.scrollWidth, height: root.scrollHeight }; + const dpr = devicePixelRatio; + return { + layoutViewport: { + pageX: layout.pageX * dpr, + pageY: layout.pageY * dpr, + clientWidth: layout.clientWidth * dpr, + clientHeight: layout.clientHeight * dpr, + }, + // Chrome leaves the visual viewport's position in CSS pixels. + visualViewport: { ...visual, clientWidth: visual.clientWidth * dpr, clientHeight: visual.clientHeight * dpr }, + contentSize: { ...content, width: content.width * dpr, height: content.height * dpr }, + cssLayoutViewport: layout, + cssVisualViewport: visual, + cssContentSize: content, + }; + }, + getNavigationHistory: (): Protocol.Page.GetNavigationHistoryResponse => ({ currentIndex: 0, entries: [ diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/render.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/render.ts new file mode 100644 index 0000000..35652e1 --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/render.ts @@ -0,0 +1,81 @@ +import { isOwn } from '../../core/nodes'; + +/** A rectangle in page (document) coordinates, CSS pixels. */ +export interface PageRect { + x: number; + y: number; + width: number; + height: number; +} + +/** The part of snapDOM (`@zumer/snapdom`) the screencast and screenshots use. */ +export type Snapdom = ( + element: Element, + options: { + clip: 'viewport' | PageRect; + exclude: ((el: Element) => boolean)[]; + excludeMode: 'hide'; + backgroundColor: string; + } +) => Promise<{ toCanvas(options: { scale: number; dpr: number }): Promise }>; + +/** Loads snapDOM on demand: it is large, and only the screencast and screenshots need it. @internal */ +export type SnapdomLoader = () => Promise<{ snapdom: Snapdom }>; + +/** + * Renders the page's DOM as an image, since page scripts cannot read the pixels on screen: + * the viewport or a region of the page. DevTools' own elements are left out. + */ +export type Render = (clip: 'viewport' | PageRect, scale: number, dpr: number) => Promise; + +/** The color the browser paints behind the page: the root's or body's background, or white. */ +function background() { + for (const el of [document.documentElement, document.body]) { + const color = el && getComputedStyle(el).backgroundColor; + if (color && color !== 'transparent' && !/^rgba\(.*,\s*0\)$/.test(color)) return color; + } + return '#fff'; +} + +/** + * A failure before drawing: loading snapDOM or reading the page. Drawing at a smaller size would + * not help, unlike a failure to draw. + */ +export class CaptureError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + } +} + +/** A Render on snapDOM, loaded on first use. */ +export function createRender(load: SnapdomLoader): Render { + let snapdom: Promise | null = null; + return async (clip, scale, dpr) => { + let result; + try { + // A failed load is not kept, so that the next render loads snapDOM again. + const capture = await (snapdom ??= load().then( + m => m.snapdom, + (e: unknown) => { + snapdom = null; + throw e; + } + )); + result = await capture(document.documentElement, { + clip, + exclude: [isOwn], + excludeMode: 'hide', + backgroundColor: background(), + }); + } catch (e) { + throw new CaptureError(e); + } + return result.toCanvas({ scale, dpr }); + }; +} + +/** The canvas as base64 image data, in a CDP image format (`quality` from 0 to 100). */ +export function encode(canvas: HTMLCanvasElement, format: 'png' | 'jpeg' | 'webp', quality: number | undefined) { + const url = canvas.toDataURL(`image/${format}`, (quality ?? 80) / 100); + return url.slice(url.indexOf(',') + 1); +} diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/screencast.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/screencast.ts index 64543c4..d3fce99 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/page/screencast.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/screencast.ts @@ -1,43 +1,18 @@ import type { Protocol } from 'devtools-protocol'; import type { Context } from '../../core/context'; -import { isOwn } from '../../core/nodes'; import { CdpError } from '../../core/session'; - -/** The part of snapDOM (`@zumer/snapdom`) the screencast uses. */ -export type Snapdom = ( - element: Element, - options: { - clip: 'viewport'; - exclude: ((el: Element) => boolean)[]; - excludeMode: 'hide'; - backgroundColor: string; - } -) => Promise<{ toCanvas(options: { scale: number; dpr: number }): Promise }>; - -/** Loads snapDOM on demand: it is large, and only the screencast needs it. @internal */ -export type SnapdomLoader = () => Promise<{ snapdom: Snapdom }>; - -/** The color the browser paints behind the page: the root's or body's background, or white. */ -function background() { - for (const el of [document.documentElement, document.body]) { - const color = el && getComputedStyle(el).backgroundColor; - if (color && color !== 'transparent' && !/^rgba\(.*,\s*0\)$/.test(color)) return color; - } - return '#fff'; -} +import { encode, type Render } from './render'; /** Shortest time between two frames, in milliseconds. */ const MIN_INTERVAL = 500; /** - * Page.startScreencast: renders the viewport with snapDOM, the page's DOM redrawn as an - * image, since page scripts cannot read the pixels on screen. A frame is sent once the + * Page.startScreencast: renders the viewport with snapDOM (render.ts). A frame is sent once the * previous one is acknowledged and at most every MIN_INTERVAL ms, or every two rendering * times when rendering is slower; nothing is rendered while the page is hidden. - * DevTools' own elements are left out. */ -export function registerScreencast({ session: cdpSession }: Context, loadSnapdom: SnapdomLoader | undefined) { - if (!loadSnapdom) { +export function registerScreencast({ session: cdpSession }: Context, render: Render | undefined) { + if (!render) { cdpSession.register('Page', { startScreencast() { throw new CdpError('Screencast is not available: snapDOM is not configured'); @@ -46,39 +21,23 @@ export function registerScreencast({ session: cdpSession }: Context, loadSnapdom }); return; } - const load = loadSnapdom; + const draw = render; let frameSession = 0; let params: Protocol.Page.StartScreencastRequest | null = null; let acked = true; let timer: ReturnType | undefined; - let snapdom: Promise | null = null; async function frame(current: number) { - // A failed load is not kept, so that the retry loads snapDOM again. - const capture = await (snapdom ??= load().then( - m => m.snapdom, - (e: unknown) => { - snapdom = null; - throw e; - } - )); const start = performance.now(); const width = innerWidth; const height = innerHeight; const scale = Math.min(1, (params?.maxWidth ?? width) / width, (params?.maxHeight ?? height) / height); - const result = await capture(document.documentElement, { - clip: 'viewport', - exclude: [isOwn], - excludeMode: 'hide', - backgroundColor: background(), - }); - const canvas = await result.toCanvas({ scale, dpr: 1 }); - const png = params?.format === 'png'; - const url = canvas.toDataURL(png ? 'image/png' : 'image/jpeg', (params?.quality ?? 80) / 100); + const canvas = await draw('viewport', scale, 1); + const data = encode(canvas, params?.format === 'png' ? 'png' : 'jpeg', params?.quality); if (current !== frameSession) return; acked = false; cdpSession.emit('Page.screencastFrame', { - data: url.slice(url.indexOf(',') + 1), + data, sessionId: current, metadata: { offsetTop: 0, diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/page/screenshot.ts b/packages/devtools-anywhere/src/cdp-agent/domains/page/screenshot.ts new file mode 100644 index 0000000..8399300 --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/domains/page/screenshot.ts @@ -0,0 +1,56 @@ +import type { Protocol } from 'devtools-protocol'; +import type { Context } from '../../core/context'; +import { CdpError } from '../../core/session'; +import { CaptureError, encode, type Render } from './render'; + +/** + * A canvas area small enough for any browser, older iOS Safari's limit (4096 × 4096). + * snapDOM already scales an image past the usual canvas limits down to fit (32767 pixels a + * side, 16384 on Safari, 16384 × 16384 in area); should drawing still fail, the screenshot is + * halved and tried again, and once it is no larger than this the failure is reported. A failure + * before drawing (CaptureError) is reported at once. + */ +const SAFE_PIXELS = 4096 * 4096; + +/** + * Page.captureScreenshot's response, with `approximate`: the image is the page's DOM redrawn + * by snapDOM, not the pixels Chrome painted (docs/usage.md, "Limitations"). + */ +export type CaptureScreenshotResponse = Protocol.Page.CaptureScreenshotResponse & { approximate: true }; + +/** + * Page.captureScreenshot, as Chrome does it: `clip` is in page coordinates whether or not + * `captureBeyondViewport` is set, and the image has device pixels, the CSS size times + * devicePixelRatio times `clip.scale`. Without a clip it is the viewport, or with + * `captureBeyondViewport` the whole page. + */ +export function registerScreenshot({ session }: Context, render: Render | undefined) { + session.register('Page', { + async captureScreenshot(params: Protocol.Page.CaptureScreenshotRequest): Promise { + if (!render) throw new CdpError('Screenshots are not available: snapDOM is not configured'); + const root = document.documentElement; + const { clip } = params; + const area = clip + ? { x: clip.x, y: clip.y, width: clip.width, height: clip.height } + : params.captureBeyondViewport + ? { x: 0, y: 0, width: root.scrollWidth, height: root.scrollHeight } + : null; + const width = area?.width ?? innerWidth; + const height = area?.height ?? innerHeight; + if (!(width > 0 && height > 0)) throw new CdpError('Cannot take screenshot with 0 width or height'); + const dpr = devicePixelRatio; + for (let scale = clip?.scale ?? 1; ; scale /= 2) { + const safe = width * height * (scale * dpr) ** 2 <= SAFE_PIXELS; + try { + // A canvas too large to draw encodes as an empty data URL (Chrome) or throws. + const data = encode(await render(area ?? 'viewport', scale, dpr), params.format ?? 'png', params.quality); + if (data) return { data, approximate: true }; + if (safe) throw new CdpError('Unable to capture screenshot'); + } catch (e) { + // Only a failure to draw is worth trying again smaller. + if (safe || e instanceof CaptureError) throw e; + } + } + }, + }); +} diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.conformance.test.ts b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.conformance.test.ts index 49d1290..c4b7db7 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.conformance.test.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.conformance.test.ts @@ -79,6 +79,28 @@ describe('Runtime.consoleAPICalled arguments', () => { } }); +describe('console.table', () => { + // The frontend draws the table from the rows' previews: they must match Chrome's whole. + const cases = { + objects: "console.table([{ a: 1, b: 'x' }, { a: 2, c: [1, 2] }])", + arrays: 'console.table([[1, 2], [3, 4]])', + objectOfObjects: 'console.table({ r1: { a: 1 }, r2: { b: { c: 1 } } })', + columns: "console.table([{ a: 1, b: 2 }, { a: 3, b: 4 }], ['b'])", + columnsNotAnArray: "console.table([{ a: 1 }], 'a')", + manyColumns: 'console.table([Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`k${i}`, i]))])', + manyRows: 'console.table(Array.from({ length: 1200 }, (_, i) => ({ a: i })))', + primitive: "console.table('x', 1)", + }; + const withoutIds = (args: unknown) => JSON.parse(JSON.stringify(args, (k, v) => (k === 'objectId' ? undefined : v))); + for (const [name, expr] of Object.entries(cases)) { + test(name, async () => { + const { ours, chrome } = await logged(expr); + assert.equal(ours.type, chrome.type); + assert.deepEqual(withoutIds(ours.args), withoutIds(chrome.args)); + }); + } +}); + describe('Runtime.consoleAPICalled types', () => { const cases = { warn: "console.warn('w')", diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.ts b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.ts index c3ae1d8..e4e8917 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/runtime/runtime.ts @@ -11,7 +11,7 @@ import { } from '../../core/lexer'; import { CdpError } from '../../core/session'; import { parseStack } from '../../core/stack'; -import type { LogEntry } from '../../probe/types'; +import type { ConsoleEntry, LogEntry } from '../../probe/types'; import { refetch } from '../network/network'; // Indirect eval runs in the global scope, like code typed into the console. @@ -625,11 +625,24 @@ export function registerRuntime({ session, objects, nodes, probe }: Context) { }; } + /** + * Like V8, console.table of an object sends that object alone, previewed as a table; the + * columns to show, if given as an array, filter its rows. + */ + function consoleArgs(entry: ConsoleEntry) { + const [data, columns] = entry.args; + if (entry.type === 'table' && typeof data === 'object' && data !== null) { + const names = Array.isArray(columns) ? columns.map(String) : undefined; + return [objects.wrap(data, { group: 'console', table: { columns: names } })]; + } + return entry.args.map(arg => objects.wrap(arg, { generatePreview: true, group: 'console' })); + } + function emitLog(entry: LogEntry) { if (entry.kind === 'console') { session.emit('Runtime.consoleAPICalled', { type: entry.type, - args: entry.args.map(arg => objects.wrap(arg, { generatePreview: true, group: 'console' })), + args: consoleArgs(entry), executionContextId: EXECUTION_CONTEXT_ID, timestamp: entry.time, // Drop the probe's console wrapper frame. diff --git a/packages/devtools-anywhere/src/cdp-agent/domains/stubs.ts b/packages/devtools-anywhere/src/cdp-agent/domains/stubs.ts index df5fa79..2e0af6b 100644 --- a/packages/devtools-anywhere/src/cdp-agent/domains/stubs.ts +++ b/packages/devtools-anywhere/src/cdp-agent/domains/stubs.ts @@ -19,7 +19,17 @@ const NOOPS = { Autofill: ['enable', 'setAddresses'], BackgroundService: ['startObserving'], CSS: ['trackComputedStyleUpdatesForNode'], - Emulation: ['setEmulatedMedia', 'setAutoDarkModeOverride', 'setEmulatedVisionDeficiency', 'setFocusEmulationEnabled'], + // Device mode's size is the shell's (shell-layout.ts); the rest a page cannot change: the user + // agent, safe areas, a hinge or cutout, the pinch zoom. + Emulation: [ + 'setEmulatedMedia', + 'setAutoDarkModeOverride', + 'setEmulatedVisionDeficiency', + 'setFocusEmulationEnabled', + 'setSafeAreaInsetsOverride', + 'resetPageScaleFactor', + 'setUserAgentOverride', + ], HeapProfiler: ['enable'], Inspector: ['enable'], Log: ['enable', 'clear', 'startViolationsReport'], @@ -31,6 +41,8 @@ const NOOPS = { 'setShowScrollSnapOverlays', 'setShowContainerQueryOverlays', 'setShowIsolatedElements', + 'setShowHinge', + 'setShowDisplayCutout', ], Network: [ 'overrideNetworkState', @@ -41,6 +53,7 @@ const NOOPS = { 'setBypassServiceWorker', 'setExtraHTTPHeaders', 'setBlockedURLs', + 'setUserAgentOverride', ], Preload: ['enable'], Profiler: ['enable'], diff --git a/packages/devtools-anywhere/src/cdp-agent/index.ts b/packages/devtools-anywhere/src/cdp-agent/index.ts index 47a2676..8202899 100644 --- a/packages/devtools-anywhere/src/cdp-agent/index.ts +++ b/packages/devtools-anywhere/src/cdp-agent/index.ts @@ -12,7 +12,9 @@ import { registerNetwork } from './domains/network/network'; import { registerOverlay } from './domains/overlay/overlay'; import { registerPage } from './domains/page/page'; import { registerRuntime } from './domains/runtime/runtime'; -import { registerScreencast, type SnapdomLoader } from './domains/page/screencast'; +import { createRender, type SnapdomLoader } from './domains/page/render'; +import { registerScreencast } from './domains/page/screencast'; +import { registerScreenshot } from './domains/page/screenshot'; import { registerStorage } from './domains/storage/storage'; import { registerStubs } from './domains/stubs'; import { registerWebMcp } from './domains/webmcp/webmcp'; @@ -33,14 +35,16 @@ export { storeShellConfig, storeShellOpen, } from './probe/shell'; +export { isTrusted } from './probe/trust'; export { FRAME_ID, LOADER_ID } from './core/context'; -export type { SnapdomLoader } from './domains/page/screencast'; +export type { SnapdomLoader } from './domains/page/render'; /** @internal */ export interface CdpSessionOptions { /** * Loads snapDOM (`() => import('@zumer/snapdom')`), which renders the frames of - * Page.startScreencast. Without it, screencasting is not available. + * Page.startScreencast and the images of Page.captureScreenshot. Without it, neither is + * available. */ loadSnapdom?: SnapdomLoader; } @@ -88,7 +92,9 @@ export function createCdpSession(options: CdpSessionOptions = {}): CdpSession { registerStorage(ctx); registerDomDebugger(ctx); registerDebugger(ctx); - registerScreencast(ctx, options.loadSnapdom); + const render = options.loadSnapdom && createRender(options.loadSnapdom); + registerScreencast(ctx, render); + registerScreenshot(ctx, render); registerInput(ctx); registerWebMcp(ctx); return ctx.session; diff --git a/packages/devtools-anywhere/src/cdp-agent/probe/index.ts b/packages/devtools-anywhere/src/cdp-agent/probe/index.ts index 8dc1e6e..6b53563 100644 --- a/packages/devtools-anywhere/src/cdp-agent/probe/index.ts +++ b/packages/devtools-anywhere/src/cdp-agent/probe/index.ts @@ -20,6 +20,7 @@ import { isShellPage, readShellConfig, writeShellDocument } from './shell'; export * from './types'; export * from './shell'; +export { cdnFrontendUrl } from './trust'; /** The probe keeps everything up to a coarse bound, for pages that never load the CDP agent. */ const LIMIT = 10_000; diff --git a/packages/devtools-anywhere/src/cdp-agent/probe/shell.ts b/packages/devtools-anywhere/src/cdp-agent/probe/shell.ts index 8c3974b..5e9cb16 100644 --- a/packages/devtools-anywhere/src/cdp-agent/probe/shell.ts +++ b/packages/devtools-anywhere/src/cdp-agent/probe/shell.ts @@ -8,6 +8,8 @@ // storing the config itself on the first load, unless shell mode is turned off (see // SHELL_MODE_KEY, SHELL_REFUSED_KEY). +import { type Trust, isTrusted, trustOf } from './trust'; + /** @internal */ export const SHELL_KEY = 'devtools-anywhere:shell'; /** @@ -44,44 +46,35 @@ export interface ShellConfig { /** What the shell's element takes from the page besides its URLs. @internal */ export type ShellFlags = Pick; -let trustedOrigin = ''; +let trust: Trust | null = null; /** - * Sets the origin shell configs must come from: that of the script that read them (the probe, - * or the bundle). Its URL is taken while the script runs synchronously. Unset, the page's own - * origin is used. + * Sets where shell configs may point, from the URL of the script that reads them (the probe, or + * the bundle; trust.ts). Its URL is taken while the script runs synchronously. Unset, the page's + * own origin is trusted. * * @internal */ export function setShellScriptUrl(url: string) { - try { - trustedOrigin = new URL(url).origin; - } catch { - // Keep the previous origin. - } + // An invalid URL keeps the previous trust. + trust = trustOf(url) ?? trust; } /** - * True when every URL of the config is http(s) on the trusted origin. sessionStorage can be - * written by any script of the page, so a config pointing elsewhere is not trusted. Only the - * origin is checked: the frontend, bundle and probe may sit in different directories (e.g. the - * Vite plugin's base vs. a zero-config `data-devtools-frontend-url`). The remote server, which - * may be anywhere, is not kept here for that reason: the shell's probe gives it to the pages in - * the shell itself (see probe.ts). + * True when every URL of the config is trusted (trust.ts). sessionStorage can be written by any + * script of the page, so a config pointing elsewhere is not trusted. Off npm CDNs only the origin + * is checked: the frontend, bundle and probe may sit in different directories (e.g. the Vite + * plugin's base vs. a zero-config `data-devtools-frontend-url`). The remote server, which may be + * anywhere, is not kept here for that reason: the shell's probe gives it to the pages in the + * shell itself (see probe.ts). * * @internal */ export function isTrustedShellConfig(config: ShellConfig): boolean { - const origin = trustedOrigin || location.origin; - return [config.frontend, config.bundle, config.serviceWorker].every(url => { - if (url === undefined) return true; - try { - const parsed = new URL(url); - return /^https?:$/.test(parsed.protocol) && parsed.origin === origin; - } catch { - return false; - } - }); + const trusted = trust ?? { origin: location.origin }; + return [config.frontend, config.bundle, config.serviceWorker].every( + url => url === undefined || isTrusted(url, trusted) + ); } /** diff --git a/packages/devtools-anywhere/src/cdp-agent/probe/trust.test.ts b/packages/devtools-anywhere/src/cdp-agent/probe/trust.test.ts new file mode 100644 index 0000000..5c0ba45 --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/probe/trust.test.ts @@ -0,0 +1,91 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cdnFrontendUrl, isTrusted, trustOf } from './trust.ts'; + +const JSDELIVR = 'https://cdn.jsdelivr.net/npm/'; +const PROBE = `${JSDELIVR}devtools-anywhere@0.2.0/dist/probe.iife.js`; +const FRONTEND = `${JSDELIVR}@devtools-anywhere/devtools-frontend@0.2.0/dist/anywhere_app.html`; + +/** Whether a script at `script` trusts `url`. */ +const trusts = (script: string, url: string) => { + const trust = trustOf(script); + assert.ok(trust); + return isTrusted(url, trust); +}; + +describe('the frontend by default', () => { + test('on jsDelivr and unpkg, the frontend package of the same version', () => { + assert.equal(cdnFrontendUrl(PROBE), FRONTEND); + assert.equal( + cdnFrontendUrl('https://unpkg.com/devtools-anywhere@0.2.0/dist/probe.iife.js'), + 'https://unpkg.com/@devtools-anywhere/devtools-frontend@0.2.0/dist/anywhere_app.html' + ); + // A dist-tag is kept as it is. + assert.equal( + cdnFrontendUrl(`${JSDELIVR}devtools-anywhere@latest/dist/probe.iife.js`), + `${JSDELIVR}@devtools-anywhere/devtools-frontend@latest/dist/anywhere_app.html` + ); + }); + + for (const [what, script] of Object.entries({ + 'self-hosted': 'https://example.com/devtools-anywhere/dist/probe.iife.js', + 'without a version': `${JSDELIVR}devtools-anywhere/dist/probe.iife.js`, + 'another package': `${JSDELIVR}devtools-anywhere-fork@0.2.0/dist/probe.iife.js`, + 'an escaped version': `${JSDELIVR}devtools-anywhere@0.2.0%2F..%2Fx/dist/probe.iife.js`, + 'no URL': 'probe.iife.js', + })) { + test(`none ${what}`, () => assert.equal(cdnFrontendUrl(script), null)); + } +}); + +describe('what a shell config may point to', () => { + test('on an npm CDN, this package and the frontend package at the same version', () => { + assert.equal(trusts(PROBE, FRONTEND), true); + assert.equal(trusts(PROBE, `${JSDELIVR}devtools-anywhere@0.2.0/dist/devtools-anywhere.iife.js`), true); + }); + + for (const [what, url] of Object.entries({ + 'another version of the frontend': `${JSDELIVR}@devtools-anywhere/devtools-frontend@0.1.0/dist/anywhere_app.html`, + 'another version of this package': `${JSDELIVR}devtools-anywhere@0.1.0/dist/devtools-anywhere.iife.js`, + 'another package': `${JSDELIVR}evil@1.0.0/anywhere_app.html`, + 'a package that starts the same': `${JSDELIVR}devtools-anywhere@0.2.0-evil/dist/devtools-anywhere.iife.js`, + 'a scope that starts the same': `${JSDELIVR}@devtools-anywhere/devtools-frontend@0.2.0x/dist/anywhere_app.html`, + 'dot segments': `${JSDELIVR}devtools-anywhere@0.2.0/../evil@1.0.0/x.html`, + 'escaped dot segments': `${JSDELIVR}devtools-anywhere@0.2.0/%2e%2e/evil@1.0.0/x.html`, + 'escaped slashes': `${JSDELIVR}devtools-anywhere@0.2.0/..%2F..%2Fevil@1.0.0/x.html`, + 'escaped backslashes': `${JSDELIVR}devtools-anywhere@0.2.0/..%5C..%5Cevil@1.0.0/x.html`, + backslashes: `${JSDELIVR}devtools-anywhere@0.2.0\\..\\..\\evil@1.0.0/x.html`, + 'another CDN': 'https://unpkg.com/@devtools-anywhere/devtools-frontend@0.2.0/dist/anywhere_app.html', + 'plain http': FRONTEND.replace('https:', 'http:'), + credentials: FRONTEND.replace('https://', 'https://user@'), + 'not http': `javascript:alert(1)//${FRONTEND}`, + })) { + test(`not ${what}`, () => assert.equal(trusts(PROBE, url), false)); + } + + test('on unpkg too, and at a dist-tag, the same', () => { + const unpkg = 'https://unpkg.com/'; + const probe = `${unpkg}devtools-anywhere@0.2.0/dist/probe.iife.js`; + assert.equal(trusts(probe, `${unpkg}@devtools-anywhere/devtools-frontend@0.2.0/dist/anywhere_app.html`), true); + assert.equal(trusts(probe, `${unpkg}evil@1.0.0/anywhere_app.html`), false); + assert.equal(trusts(probe, FRONTEND), false); + const latest = `${JSDELIVR}devtools-anywhere@latest/dist/probe.iife.js`; + assert.equal(trusts(latest, `${JSDELIVR}@devtools-anywhere/devtools-frontend@latest/dist/anywhere_app.html`), true); + assert.equal(trusts(latest, FRONTEND), false); + }); + + test('on an npm CDN but not this package, nothing', () => { + const other = `${JSDELIVR}evil@1.0.0/probe.iife.js`; + assert.equal(trusts(other, `${JSDELIVR}evil@1.0.0/anywhere_app.html`), false); + assert.equal(trusts(other, FRONTEND), false); + }); + + test('elsewhere, the whole origin of the script', () => { + const probe = 'https://example.com/static/probe.iife.js'; + assert.equal(trusts(probe, 'https://example.com/other/anywhere_app.html'), true); + assert.equal(trusts(probe, 'https://example.org/anywhere_app.html'), false); + assert.equal(trusts(probe, 'https://example.com:8443/anywhere_app.html'), false); + }); + + test('no URL, no trust', () => assert.equal(trustOf('probe.iife.js'), null)); +}); diff --git a/packages/devtools-anywhere/src/cdp-agent/probe/trust.ts b/packages/devtools-anywhere/src/cdp-agent/probe/trust.ts new file mode 100644 index 0000000..922cc45 --- /dev/null +++ b/packages/devtools-anywhere/src/cdp-agent/probe/trust.ts @@ -0,0 +1,76 @@ +// What a shell config may point to, from the URL of the script that reads it (the probe, or the +// bundle). sessionStorage can be written by any script of the page, so a config is trusted only +// where that script says. On a public npm CDN (jsDelivr, unpkg), where anyone can publish, its +// origin is everyone's: only this package and the frontend's, at the script's version, are +// trusted, and the frontend is found there by default. Elsewhere, the script's origin is. + +/** The CDNs whose paths are npm packages: `@/`. */ +const NPM_CDNS = ['https://cdn.jsdelivr.net/npm/', 'https://unpkg.com/']; +const PACKAGE = 'devtools-anywhere'; +const FRONTEND_PACKAGE = '@devtools-anywhere/devtools-frontend'; +/** The frontend's entry page in its package. */ +const FRONTEND_ENTRY = 'dist/anywhere_app.html'; +/** A version or dist-tag, as in a CDN path: no `/`, nothing escaped. */ +const VERSION = /^[0-9A-Za-z.+-]+$/; + +/** Where URLs are trusted: an origin, or (on an npm CDN) URL prefixes, maybe none. */ +export type Trust = { origin: string } | { prefixes: string[] }; + +/** The package directories at the version of `script`, a URL of this package on an npm CDN. */ +function cdnPackages(script: URL): { own: string; frontend: string } | null { + const cdn = NPM_CDNS.find(prefix => script.href.startsWith(prefix)); + if (!cdn) return null; + const [name, rest] = splitOnce(script.href.slice(cdn.length), '/'); + const [pkg, version] = splitOnce(name, '@'); + if (pkg !== PACKAGE || !VERSION.test(version) || !rest) return null; + return { own: `${cdn}${PACKAGE}@${version}/`, frontend: `${cdn}${FRONTEND_PACKAGE}@${version}/` }; +} + +function splitOnce(s: string, separator: string): [string, string] { + const at = s.indexOf(separator); + return at < 0 ? [s, ''] : [s.slice(0, at), s.slice(at + 1)]; +} + +/** What a script at `scriptUrl` trusts; `null` when it is no URL. @internal */ +export function trustOf(scriptUrl: string): Trust | null { + let script: URL; + try { + script = new URL(scriptUrl); + } catch { + return null; + } + const packages = cdnPackages(script); + if (packages) return { prefixes: [packages.own, packages.frontend] }; + // Another package on the CDN, or no version: nothing there is ours. + if (NPM_CDNS.some(prefix => script.href.startsWith(prefix))) return { prefixes: [] }; + return { origin: script.origin }; +} + +/** Whether `url` is an http(s) URL that `trust` trusts. @internal */ +export function isTrusted(url: string, trust: Trust): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (!/^https?:$/.test(parsed.protocol)) return false; + if ('origin' in trust) return parsed.origin === trust.origin; + // Parsed, `.` and `..` segments are resolved, `%2e` ones included; an escaped `/` or `\` is not, + // and a CDN may still take it for one. + if (/%2f|%5c/i.test(parsed.pathname)) return false; + return trust.prefixes.some(prefix => parsed.href.startsWith(prefix)); +} + +/** + * The frontend's entry page at the version of `scriptUrl`, when it is this package on an npm CDN: + * the frontend to use by default. @internal + */ +export function cdnFrontendUrl(scriptUrl: string): string | null { + try { + const packages = cdnPackages(new URL(scriptUrl)); + return packages && packages.frontend + FRONTEND_ENTRY; + } catch { + return null; + } +} diff --git a/packages/devtools-anywhere/src/connection.ts b/packages/devtools-anywhere/src/connection.ts index 65b4226..42e72c0 100644 --- a/packages/devtools-anywhere/src/connection.ts +++ b/packages/devtools-anywhere/src/connection.ts @@ -124,6 +124,13 @@ export function connectToShell(session: CdpSession): () => void { return () => connection.disconnect(); } +/** The page's size in device mode, in CSS pixels (0 for "as laid out"), and its scale. */ +export interface DeviceMetrics { + width: number; + height: number; + scale: number; +} + /** The shell's side of the relay core: one frontend, in an iframe; the page in another. */ class ShellRelay extends RelayCore { private frontendReady = false; @@ -135,7 +142,8 @@ class ShellRelay extends RelayCore { constructor( private readonly page: HTMLIFrameElement, private readonly devtools: HTMLIFrameElement, - private readonly frontendOrigin: string + private readonly frontendOrigin: string, + private readonly onMetrics: (metrics: DeviceMetrics | null) => void ) { super(); this.session = this.openSession({ send: message => this.postFrontend(message) }); @@ -168,6 +176,31 @@ class ShellRelay extends RelayCore { private onPagehide = () => this.pagehide(); + /** + * Device mode's page size is the shell's to set (shell-layout.ts): it answers + * Emulation.setDeviceMetricsOverride and clearDeviceMetricsOverride itself, as Chrome's browser + * does, rather than the page. + */ + private emulate(data: string): boolean { + if (!data.includes('DeviceMetricsOverride')) return false; + const message = parse(data); + const method = message?.method; + if (method !== 'Emulation.setDeviceMetricsOverride' && method !== 'Emulation.clearDeviceMetricsOverride') + return false; + const params: unknown = message?.params; + const number = (name: string) => { + const value: unknown = typeof params === 'object' && params !== null ? Reflect.get(params, name) : undefined; + return typeof value === 'number' && Number.isFinite(value) ? value : 0; + }; + this.onMetrics( + method === 'Emulation.clearDeviceMetricsOverride' + ? null + : { width: number('width'), height: number('height'), scale: number('scale') || 1 } + ); + this.postFrontend(JSON.stringify({ id: message?.id, result: {} })); + return true; + } + readonly onMessage = (event: MessageEvent) => { if (typeof event.data !== 'string') return; if (event.source === this.devtools.contentWindow && event.origin === this.frontendOrigin) { @@ -175,7 +208,7 @@ class ShellRelay extends RelayCore { this.frontendReady = true; this.toFrontend.splice(0).forEach(message => this.postFrontend(message)); } - this.clientMessage(this.session, event.data); + if (!this.emulate(event.data)) this.clientMessage(this.session, event.data); } else if (event.source === this.page.contentWindow && event.origin === location.origin) { if (event.data.startsWith(`{"method":"${HELLO_METHOD}"`)) { const params = parse(event.data)?.params; @@ -198,8 +231,13 @@ class ShellRelay extends RelayCore { * reloads (the relay core's work, shared with the remote server). Returns a function that * disconnects. */ -export function relayShell(page: HTMLIFrameElement, devtools: HTMLIFrameElement, frontendOrigin: string): () => void { - const relay = new ShellRelay(page, devtools, frontendOrigin); +export function relayShell( + page: HTMLIFrameElement, + devtools: HTMLIFrameElement, + frontendOrigin: string, + onMetrics: (metrics: DeviceMetrics | null) => void +): () => void { + const relay = new ShellRelay(page, devtools, frontendOrigin, onMetrics); window.addEventListener('message', relay.onMessage); page.contentWindow?.postMessage(PING, location.origin); return () => window.removeEventListener('message', relay.onMessage); diff --git a/packages/devtools-anywhere/src/element.ts b/packages/devtools-anywhere/src/element/index.ts similarity index 55% rename from packages/devtools-anywhere/src/element.ts rename to packages/devtools-anywhere/src/element/index.ts index 63bccac..175ac04 100644 --- a/packages/devtools-anywhere/src/element.ts +++ b/packages/devtools-anywhere/src/element/index.ts @@ -3,8 +3,8 @@ import { SHELL_KEY, SHELL_PAGE_ID, createCdpSession, - isShellDocument, isShellPage, + isTrusted, markOwn, OWN_ATTRIBUTE, readShellConfig, @@ -14,22 +14,49 @@ import { storeShellOpen, type CdpSession, type SnapdomLoader, -} from './cdp-agent'; -import { connectFrontend, relayShell } from './connection'; -import { SHELL_HANDSHAKE } from './shell-messages'; -import type { GlobalElementConfig } from './api'; -import type { DevtoolsAnywhereAttributes, TogglerAttributes } from './public-types'; +} from '../cdp-agent'; +import { connectFrontend, relayShell } from '../connection'; +import { SHELL_HANDSHAKE } from '../shell-messages'; +import type { Grip } from '../grip'; +import { isDockSide, isEmbedderRequest } from './request'; +import { + createPanelGrip, + DOCK_KEY, + GRIP_EDGE, + readStored, + saveFloat, + setupCorner, + setupMover, + sizePanel, + store, +} from './panel'; +import { ShellLayout } from '../shell-layout'; +import { STYLE } from './style'; +import { + CONTROL_EVENT, + customElementsLoading, + globalConfigs, + globalElements, + globals, + inactiveGlobals, + isCustom, + isShellElement, + ownElements, + shellTogglers, + STATE_EVENT, +} from './registry'; +import { controlElement, installTogglers, refreshTogglers, shellElement } from './togglers'; +import type { GlobalElementConfig } from '../api'; +import type { DevtoolsAnywhereAttributes } from '../public-types'; import { - DOCK_SIDES, HIDDEN_NOTICE, PROBE_SCRIPT, SNAPDOM_SCRIPT, REQUEST_KEY, type DockSide, - type EmbedderRequest, type FrontendNotice, type FrontendHash, -} from './embedder'; +} from '../embedder'; let bundleUrl = ''; @@ -51,66 +78,6 @@ let loadSnapdom: SnapdomLoader = () => Promise.reject(new Error('snapDOM: the bu /** The CDP session of a page, with the screencast's renderer. */ export const createPageSession = () => createCdpSession({ loadSnapdom: () => loadSnapdom() }); -// Sizes are remembered as percentages of the viewport, so they fit any screen and orientation. -const HEIGHT_KEY = 'devtools-anywhere:height%'; -const WIDTH_KEY = 'devtools-anywhere:width%'; -const FLOAT_KEY = 'devtools-anywhere:float%'; -/** Viewport units that follow the browser's collapsing toolbars on mobile, where supported. */ -const VH = typeof CSS !== 'undefined' && CSS.supports('height', '1dvh') ? 'dvh' : 'vh'; -const VW = typeof CSS !== 'undefined' && CSS.supports('width', '1dvw') ? 'dvw' : 'vw'; -/** `part` as a percentage of `whole`, to one decimal. */ -const percent = (part: number, whole: number) => Math.round((part / whole) * 1000) / 10; -const DOCK_KEY = 'devtools-anywhere:dock'; -const MIN_SIZE = 80; - -/** - * localStorage, which throws when it is unavailable: Android WebViews without DOM storage, - * Safari blocking all cookies, sandboxed iframes. Sizes and dock side are only preferences. - */ -function readStored(key: string): string | null { - try { - return localStorage.getItem(key); - } catch { - return null; - } -} -function store(key: string, value: string) { - try { - localStorage.setItem(key, value); - } catch {} -} -/** A finite number from a stored value, else undefined. */ -const finite = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined); - -const isDockSide = (value: unknown): value is DockSide => DOCK_SIDES.some(side => side === value); - -/** - * Checks the fields of each type of request from the frontend, which is built separately: a type - * added to EmbedderRequest (embedder.ts) does not compile here until it is checked. - */ -const REQUEST_CHECKS: { [T in EmbedderRequest['type']]: (request: object) => boolean } = { - dock: request => 'side' in request && isDockSide(request.side), - close: () => true, - 'keep-open': request => 'keep' in request && typeof request.keep === 'boolean', - theme: request => ['toolbar', 'divider', 'icon'].every(key => typeof Reflect.get(request, key) === 'string'), -}; -const isRequestType = (type: unknown): type is EmbedderRequest['type'] => - typeof type === 'string' && Object.hasOwn(REQUEST_CHECKS, type); -const isEmbedderRequest = (value: unknown): value is EmbedderRequest => - typeof value === 'object' && - value !== null && - 'type' in value && - isRequestType(value.type) && - REQUEST_CHECKS[value.type](value); - -/** Position and size of the undocked panel, in percent of the viewport. */ -interface FloatRect { - left: number; - top: number; - width: number; - height: number; -} - /** * Whether the browser has the top layer for popovers. There the toggler and the panel are above * everything the page draws, whatever its z-index; elsewhere they fall back to the highest z-index. @@ -122,102 +89,6 @@ function setPopoverOpen(el: HTMLElement, open: boolean) { if (el.matches(':popover-open') !== open) el.togglePopover(open); } -const STYLE = ` -:host { all: initial; display: block; } -:host([hidden]) { display: none; } -/* Both go into the top layer as popovers (see TOP_LAYER): undo the browser's popover style. */ -.toggler, .panel { - inset: auto; width: auto; height: auto; margin: 0; padding: 0; border: 0; overflow: visible; color: inherit; -} -.toggler { - position: fixed; z-index: 2147483647; - right: calc(16px + env(safe-area-inset-right, 0px)); bottom: calc(16px + env(safe-area-inset-bottom, 0px)); - width: 44px; height: 44px; border: 0; border-radius: 50%; - background: #1a73e8; color: #fff; font: 600 13px/44px system-ui, sans-serif; - box-shadow: 0 2px 6px rgba(0, 0, 0, .3); cursor: pointer; touch-action: manipulation; -} -.toggler[hidden] { display: none; } -.panel { - position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483647; - display: none; flex-direction: column; box-sizing: border-box; - background: #fff; border-top: 1px solid var(--bar-border, #cacdd1); - /* Clear of the screen's cutouts and rounded corners (a page with viewport-fit=cover has them): - the home indicator below, and the notch on the sides in landscape. */ - padding: 0 env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); -} -.panel[data-open] { display: flex; } -/* The bar takes the DevTools toolbar's colors, which the frontend reports (theme request). */ -.bar { - flex: none; position: relative; height: 16px; background: var(--bar-background, #f1f3f4); - cursor: row-resize; touch-action: none; -} -.bar::before { - content: ''; position: absolute; left: 50%; top: 6px; width: 32px; height: 4px; - margin-left: -16px; border-radius: 2px; background: var(--bar-handle, #bdc1c6); -} -.bar button { - position: absolute; top: 0; width: 24px; height: 16px; padding: 0; border: 0; - background: none; color: var(--bar-icon, #5f6368); font: 14px/16px system-ui, sans-serif; cursor: pointer; -} -/* DevTools' own toolbar has a close button, except when undocked: then the bar has one. */ -.close { right: 4px; display: none; } -[data-dock=undocked] .close { display: block; } -/* The undocked window's resize corner, for touch screens where CSS resize does not work. */ -.corner { - position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; display: none; - cursor: nwse-resize; touch-action: none; -} -[data-dock=undocked] .corner { display: block; } -iframe { flex: 1; min-width: 0; min-height: 0; border: 0; } -.dragging iframe { pointer-events: none; } - -/* Docked to a side: full height, with the bar as a vertical strip on the inner edge. */ -.panel[data-dock=right], .panel[data-dock=left] { - top: 0; border-top: 0; padding-top: env(safe-area-inset-top, 0px); -} -/* No inset on the inner edge, which is in the middle of the screen. */ -.panel[data-dock=right] { - left: auto; flex-direction: row; border-left: 1px solid var(--bar-border, #cacdd1); padding-left: 0; -} -.panel[data-dock=left] { - right: auto; flex-direction: row-reverse; border-right: 1px solid var(--bar-border, #cacdd1); padding-right: 0; -} -[data-dock=right] .bar, [data-dock=left] .bar { width: 16px; height: auto; cursor: col-resize; } -[data-dock=right] .bar::before, [data-dock=left] .bar::before { - left: 6px; top: 50%; width: 4px; height: 32px; margin: -16px 0 0; -} -[data-dock=right] .bar button, [data-dock=left] .bar button { left: 0; right: auto; width: 16px; } - -/* Undocked: a window over the page, moved by its bar and resized from its corner. */ -.panel[data-dock=undocked] { - right: auto; bottom: auto; border: 1px solid var(--bar-border, #cacdd1); border-radius: 6px; overflow: hidden; - box-shadow: 0 4px 16px rgba(0, 0, 0, .3); resize: both; padding: 0; -} -[data-dock=undocked] .bar { cursor: move; } - -/* Touch screens: targets large enough for a finger. */ -@media (pointer: coarse) { - .bar { height: 28px; } - .bar::before { top: 12px; } - .bar button { width: 44px; height: 28px; line-height: 28px; font-size: 18px; } - [data-dock=right] .bar, [data-dock=left] .bar { width: 28px; } - [data-dock=right] .bar::before, [data-dock=left] .bar::before { left: 12px; } - [data-dock=right] .bar button, [data-dock=left] .bar button { width: 28px; height: 44px; } - .corner { width: 32px; height: 32px; } -} - -/* An element placed by the page: a box in the page's flow, sized by its CSS, always loaded. */ -.panel[data-custom] { - position: static; display: flex; width: 100%; height: 100%; border: 0; padding: 0; -} -[data-custom] .bar, .toggler[data-custom] { display: none; } - -/* Shell mode: the panel takes its place next to the page iframe, unless undocked. */ -:host([data-shell]) { display: flex; flex: none; } -:host([data-shell]) .panel { position: static; flex: 1 1 auto; } -:host([data-shell]) .panel[data-dock=undocked] { position: fixed; } -`; - /** * `` * @@ -270,9 +141,13 @@ export class DevtoolsAnywhereElement extends HTMLElement { * (detach), as the page shows or hides it, or removes it (document.open, replaceChildren). */ private shellLink: { attach(): void; detach(): void } | null = null; + /** In the shell: places the page where the frontend lays it out (shell-layout.ts). */ + private shellLayout: ShellLayout | null = null; + private readonly grip: Grip; constructor() { super(); + ownElements.add(this); const root = this.attachShadow({ mode: 'open' }); root.innerHTML = ` @@ -299,15 +174,18 @@ export class DevtoolsAnywhereElement extends HTMLElement { }); part('.close').addEventListener('click', () => this.setOpen(false)); this.addEventListener(CONTROL_EVENT, this.onControl); - this.setupResizer(part('.bar')); - this.setupCorner(part('.corner')); + const relayout = () => this.layout(); + setupMover(part('.bar'), this.panel, relayout); + this.grip = createPanelGrip(this.panel, () => this.dock, relayout); + this.panel.append(this.grip.element); + setupCorner(part('.corner'), this.panel, relayout); // The undocked panel is resized by the browser (CSS resize): remember its size once it // settles, not on every frame of the drag. let saveTimer: ReturnType | undefined; new ResizeObserver(() => { clearTimeout(saveTimer); saveTimer = setTimeout(() => { - if (this.dock === 'undocked' && this.isOpen && this.panel.offsetWidth) this.saveFloat(); + if (this.dock === 'undocked' && this.isOpen && this.panel.offsetWidth) saveFloat(this.panel); }, 300); }).observe(this.panel); } @@ -342,16 +220,35 @@ export class DevtoolsAnywhereElement extends HTMLElement { else void this.exitShell(); } else if (request.type === 'theme') { const colors: [string, string[]][] = [ - [request.toolbar, ['--bar-background']], - [request.divider, ['--bar-handle', '--bar-border']], - [request.icon, ['--bar-icon']], + [request.toolbar, ['--bar-background', '--grip-background']], + [request.divider, ['--bar-handle', '--bar-border', '--grip-border']], + [request.icon, ['--bar-icon', '--grip-dot']], ]; + const set: Record = {}; for (const [value, properties] of colors) { if (!CSS.supports('color', value)) continue; - for (const property of properties) this.panel.style.setProperty(property, value); + for (const property of properties) set[property] = value; } + for (const [property, value] of Object.entries(set)) this.panel.style.setProperty(property, value); + // The shell's grip is outside this element. + this.shellLayout?.setGripColors(set); } else if (request.type === 'close') { this.setOpen(false); + } else if (request.type === 'page-bounds') { + this.shellLayout?.pageBounds(request); + } else if (request.type === 'split') { + this.shellLayout?.splitArea(request); + } else if (request.type === 'toolbox') { + // Only a page next to the frontend's: on a CDN, its origin is every package's. + let url: string; + let frontendDir: string; + try { + url = new URL(request.url, this.loadedUrl).href; + frontendDir = new URL('.', this.loadedUrl).href; + } catch { + return; + } + if (isTrusted(url, { prefixes: [frontendDir] })) this.shellLayout?.showToolbox(url); } }; @@ -460,7 +357,7 @@ export class DevtoolsAnywhereElement extends HTMLElement { return ( this.getAttribute('devtools-frontend-url') || this.config.frontend || - probeFrontend || + globals.probeFrontend || (isShellPage() && readShellConfig()?.frontend) || '' ); @@ -595,14 +492,14 @@ export class DevtoolsAnywhereElement extends HTMLElement { if (this.custom) return this.connectCustom(); // Only the first global element connected works, even once it is gone. if (globalElements.has(this) && !this.firstGlobal) { - if (globalTaken) { + if (globals.taken) { if (!inactiveGlobals.has(this)) console.warn('devtools-anywhere: the page has a global already'); inactiveGlobals.add(this); this.style.setProperty('display', 'none', 'important'); return; } - globalTaken = this.firstGlobal = true; + globals.taken = this.firstGlobal = true; } // The options, once: the probe's, or in the shell the shell config's, kept for its reloads. if (this.openState === null) { @@ -630,6 +527,10 @@ export class DevtoolsAnywhereElement extends HTMLElement { if (isShellElement(this)) { // Only for its styles: what makes it the shell's is its document. this.setAttribute('data-shell', ''); + const page = document.getElementById(SHELL_PAGE_ID); + if (page instanceof HTMLIFrameElement && !this.shellLayout) { + this.shellLayout = new ShellLayout(this, this.frame, page, () => this.frontendOrigin); + } this.watchShellPage(); this.registerShellWorker(); } else { @@ -647,6 +548,8 @@ export class DevtoolsAnywhereElement extends HTMLElement { this.disconnect?.(); this.disconnect = null; this.loadedUrl = ''; + this.shellLayout?.dispose(); + this.shellLayout = null; window.removeEventListener('message', this.onFrontendRequest); window.removeEventListener('pageshow', this.onShellPageshow); // The togglers without a value may point to another element now. @@ -693,60 +596,18 @@ export class DevtoolsAnywhereElement extends HTMLElement { /** Sizes the panel for its dock side; in shell mode, also lays out the shell around it. */ private layout() { - const style = this.panel.style; - style.width = style.height = style.left = style.top = ''; const dock = this.dock; - if (dock === 'bottom') { - const stored = Number(readStored(HEIGHT_KEY)); - style.height = stored ? `${stored}${VH}` : `50${VH}`; - } else if (dock === 'undocked') { - const rect = this.floatRect(); - style.left = `${rect.left}${VW}`; - style.top = `${rect.top}${VH}`; - style.width = `${rect.width}${VW}`; - style.height = `${rect.height}${VH}`; - } else { - const stored = Number(readStored(WIDTH_KEY)); - style.width = stored ? `${stored}${VW}` : `50${VW}`; - } + sizePanel(this.panel, dock); + // Docked, the grip on the panel's inner edge resizes it; in the shell, the shell's grip does. + if (dock !== 'undocked') this.grip.place(GRIP_EDGE[dock], 'outside'); + this.grip.show(dock !== 'undocked' && !isShellElement(this) && !this.panel.hasAttribute('data-custom')); // The shell document is ours: the page iframe comes first, then this element. if (isShellElement(this)) { document.body.style.flexDirection = dock === 'right' ? 'row' : dock === 'left' ? 'row-reverse' : 'column'; + this.shellLayout?.update(this.isOpen, dock); } } - /** The undocked panel's last position and size, kept inside the viewport. */ - private floatRect(): FloatRect { - let stored: unknown = null; - try { - stored = JSON.parse(readStored(FLOAT_KEY) || 'null'); - } catch {} - // Written by an older version or by the page itself: only finite numbers are used. - const field = (key: keyof FloatRect) => - typeof stored === 'object' && stored ? finite(Reflect.get(stored, key)) : undefined; - const width = Math.min(field('width') || 80, 100); - const height = Math.min(field('height') || 60, 100); - const clamp = (value: number | undefined, fallback: number, max: number) => - Math.min(Math.max(value ?? fallback, 0), max); - return { - left: clamp(field('left'), (100 - width) / 2, 100 - width), - top: clamp(field('top'), (100 - height) / 2, 100 - height), - width, - height, - }; - } - - private saveFloat() { - const { left, top, width, height } = this.panel.getBoundingClientRect(); - const rect: FloatRect = { - left: percent(left, innerWidth), - top: percent(top, innerHeight), - width: percent(width, innerWidth), - height: percent(height, innerHeight), - }; - store(FLOAT_KEY, JSON.stringify(rect)); - } - private loadFrontend() { const frontend = this.frontendUrl; const pageElement = document.getElementById(SHELL_PAGE_ID); @@ -762,7 +623,11 @@ export class DevtoolsAnywhereElement extends HTMLElement { this.disconnect?.(); this.frontendOrigin = url.origin; if (this.session) this.disconnect = connectFrontend(this.session, this.frame, url.origin); - else if (page) this.disconnect = relayShell(page, this.frame, url.origin); + else if (page) { + // Device mode was the previous frontend's: Chrome clears it with the frontend. + this.shellLayout?.setMetrics(null); + this.disconnect = relayShell(page, this.frame, url.origin, metrics => this.shellLayout?.setMetrics(metrics)); + } // Whether shell mode is available (it needs the bundle URL) and whether it is on. const shell = isShellElement(this); const hash = new URLSearchParams({ @@ -773,98 +638,16 @@ export class DevtoolsAnywhereElement extends HTMLElement { } satisfies FrontendHash); this.frame.src = `${url.href.split('#')[0]}#?${hash}`; } - - /** Dragging the bar resizes the docked panel, or moves the undocked one. */ - private setupResizer(resizer: HTMLElement) { - resizer.addEventListener('pointerdown', down => { - if ((down.target as HTMLElement).closest('button')) return; - down.preventDefault(); - resizer.setPointerCapture(down.pointerId); - this.panel.classList.add('dragging'); - const dock = this.dock; - const start = this.panel.getBoundingClientRect(); - const clampSize = (size: number, max: number) => Math.min(Math.max(size, MIN_SIZE), max - 16); - const move = (e: PointerEvent) => { - const style = this.panel.style; - if (dock === 'bottom') style.height = `${clampSize(innerHeight - e.clientY, innerHeight)}px`; - else if (dock === 'right') style.width = `${clampSize(innerWidth - e.clientX, innerWidth)}px`; - else if (dock === 'left') style.width = `${clampSize(e.clientX, innerWidth)}px`; - else { - const left = start.left + e.clientX - down.clientX; - const top = start.top + e.clientY - down.clientY; - // The whole window stays in the viewport, so its resize corner stays reachable; a - // window larger than the viewport keeps its top left corner in view. - style.left = `${Math.max(Math.min(left, innerWidth - start.width), 0)}px`; - style.top = `${Math.max(Math.min(top, innerHeight - start.height), 0)}px`; - } - }; - const up = () => { - this.panel.classList.remove('dragging'); - if (dock === 'bottom') store(HEIGHT_KEY, String(percent(this.panel.offsetHeight, innerHeight))); - else if (dock === 'undocked') this.saveFloat(); - else store(WIDTH_KEY, String(percent(this.panel.offsetWidth, innerWidth))); - // From the pixels of the drag to the remembered percentages. - this.layout(); - resizer.removeEventListener('pointermove', move); - resizer.removeEventListener('pointerup', up); - resizer.removeEventListener('pointercancel', up); - }; - resizer.addEventListener('pointermove', move); - resizer.addEventListener('pointerup', up); - resizer.addEventListener('pointercancel', up); - }); - } - - /** Dragging the corner resizes the undocked panel, within the viewport. */ - private setupCorner(corner: HTMLElement) { - corner.addEventListener('pointerdown', down => { - down.preventDefault(); - corner.setPointerCapture(down.pointerId); - this.panel.classList.add('dragging'); - const start = this.panel.getBoundingClientRect(); - const move = (e: PointerEvent) => { - const width = start.width + e.clientX - down.clientX; - const height = start.height + e.clientY - down.clientY; - this.panel.style.width = `${Math.min(Math.max(width, MIN_SIZE), innerWidth - start.left)}px`; - this.panel.style.height = `${Math.min(Math.max(height, MIN_SIZE), innerHeight - start.top)}px`; - }; - const up = () => { - this.panel.classList.remove('dragging'); - this.saveFloat(); - this.layout(); - corner.removeEventListener('pointermove', move); - corner.removeEventListener('pointerup', up); - corner.removeEventListener('pointercancel', up); - }; - corner.addEventListener('pointermove', move); - corner.addEventListener('pointerup', up); - corner.addEventListener('pointercancel', up); - }); - } } -/** - * The global elements, created for the probe (createGlobalElement), whether one has connected, and - * those connected after it, which do nothing. - */ -const globalElements = new WeakSet(); -let globalTaken = false; -const inactiveGlobals = new WeakSet(); -/** The probe's frontend URL, for the elements placed by the page without one. */ -let probeFrontend = ''; -/** The connected elements placed by the page, each with what loads its frontend. */ -const customElementsLoading = new Map void>(); -/** The probe's options for each global element, taken when it first connects. */ -const globalConfigs = new WeakMap(); - /** Creates a global element with the probe's options: only the first one connected works. @internal */ export function createGlobalElement(config: GlobalElementConfig): DevtoolsAnywhereElement | null { try { const element = new DevtoolsAnywhereElement(); globalElements.add(element); globalConfigs.set(element, config); - if (config.frontend && !probeFrontend) { - probeFrontend = config.frontend; + if (config.frontend && !globals.probeFrontend) { + globals.probeFrontend = config.frontend; customElementsLoading.forEach(load => load()); } return element; @@ -875,9 +658,6 @@ export function createGlobalElement(config: GlobalElementConfig): DevtoolsAnywhe } } -/** The F12 buttons of the shell's element, in the shell's page (mountShellToggler). */ -const shellTogglers = new WeakSet(); - /** * In the shell's page iframe: mounts the shell element's F12 button in the page, under like * the global element, so that the page's `::part(toggler)` styles reach it. The shell config @@ -898,143 +678,6 @@ export function mountShellToggler() { else mount(); } -/** What a toggler fires on an element placed by the page, which has no open state. */ -const TOGGLE_REQUEST_EVENT = 'devtools-toggle-request'; -/** Whether `el` is an element placed by the page (of this window). */ -const isCustom = (el: Element) => - el instanceof DevtoolsAnywhereElement && !globalElements.has(el) && !shellTogglers.has(el) && !isShellElement(el); -/** - * Whether `el` is the shell's element: one in the shell document the probe wrote (not created - * for the probe), maybe of another window. Its document is marked as the shell's when the probe writes the - * shell; a `shell` attribute no longer makes an element the shell's. - */ -const isShellElement = (el: Element) => !globalElements.has(el) && isShellDocument(el.ownerDocument); - -/** - * Fired on the global element when it opens or closes, for the togglers of the shell's page: an - * internal convention, not part of the API, which has only their aria-expanded. A page of the - * same origin can fire it too: it is not meant to be trusted. - */ -const STATE_EVENT = 'devtools-anywhere-state'; - -/** - * The event a toggler controls its element with (see controlElement): an internal convention, not - * part of the API. A page of the same origin can fire it too: it is not meant to be trusted. - */ -const CONTROL_EVENT = 'devtools-anywhere-control'; - -/** Elements with this attribute open and close DevTools when clicked (see togglerTarget). */ -const TOGGLE_ATTRIBUTE: keyof TogglerAttributes = 'data-devtools-anywhere-toggle'; -/** The tag the togglers look for, once they are installed. */ -let togglerTag = ''; -/** Toggler values already warned about. */ -const warned = new Set(); - -/** - * The element a toggler controls: the element whose id is the attribute's value, or without one - * the first global element, or in the shell's page iframe the shell's element. - */ -function togglerTarget(toggler: Element, warn: boolean): Element | null { - const id = toggler.getAttribute(TOGGLE_ATTRIBUTE) ?? ''; - const doc = toggler.ownerDocument; - let target: Element | null | undefined = null; - if (isShellPage()) { - // An element the page placed in the shell's page: the toggler names it. Without a value, the - // shell's element, of the shell window's class: it is toggled through an event. - const named = id ? doc.getElementById(id) : null; - if (named && customElementsLoading.has(named)) return named; - if (!id) return shellElement(); - } else { - // Without a value, the global element (or the shell's); global elements after the first do - // nothing: they are not targets. - target = id - ? doc.getElementById(id) - : [...doc.querySelectorAll(togglerTag)].find( - el => (globalElements.has(el) || isShellElement(el)) && !inactiveGlobals.has(el) - ); - } - if (target instanceof DevtoolsAnywhereElement && !inactiveGlobals.has(target)) return target; - if (warn && !warned.has(id)) { - warned.add(id); - console.warn(`devtools-anywhere: no <${togglerTag}>${id ? ` with id "${id}"` : ''} for ${TOGGLE_ATTRIBUTE}`); - } - return null; -} - -/** In the shell's page iframe: the shell's element, of the shell window's class. */ -function shellElement(): Element | null { - const shellDocument = window.frameElement?.ownerDocument; - return (shellDocument && [...shellDocument.querySelectorAll(togglerTag)].find(isShellElement)) ?? null; -} - -/** - * Asks a toggler's element, maybe of another window, whether it is open, toggling it first when - * `toggle`. Undefined when it does not answer. - */ -function controlElement(target: Element, toggle: boolean): boolean | undefined { - const detail: { toggle: boolean; open?: boolean } = { toggle }; - target.dispatchEvent(new CustomEvent(CONTROL_EVENT, { detail })); - return detail.open; -} - -/** Sets aria-expanded on a toggler from the state of its element. */ -function refreshToggler(toggler: Element, warn = false) { - const target = togglerTarget(toggler, warn); - // An element placed by the page has no open state. - if (target && isCustom(target)) return target; - const open = target && controlElement(target, false); - if (open !== undefined && open !== null) toggler.setAttribute('aria-expanded', String(open)); - return target; -} - -function refreshTogglers(doc: Document) { - if (!togglerTag) return; - for (const toggler of doc.querySelectorAll(`[${TOGGLE_ATTRIBUTE}]`)) refreshToggler(toggler); -} - -/** Makes any element with TOGGLE_ATTRIBUTE, including those added later, a toggler. */ -function installTogglers(tag: string) { - if (togglerTag) return; - togglerTag = tag; - document.addEventListener( - 'click', - event => { - const clicked = event.target instanceof Element ? event.target : null; - const toggler = clicked?.closest(`[${TOGGLE_ATTRIBUTE}]`); - // A label's click is followed by one on its control: that one toggles, once. - const control = clicked?.closest('label')?.control; - if (!toggler || (control && control !== clicked && toggler.contains(control))) return; - const target = toggler && refreshToggler(toggler, true); - if (!target) return; - // An element placed by the page only tells its listeners, which decide. - if (isCustom(target)) target.dispatchEvent(new Event(TOGGLE_REQUEST_EVENT)); - else controlElement(target, true); - refreshToggler(toggler); - }, - true - ); - const refresh = () => refreshTogglers(document); - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', refresh); - else refresh(); - // In the shell's page iframe, the togglers follow the shell's element while this page is shown. - if (!isShellPage()) return; - const shellDocument = window.frameElement?.ownerDocument; - let shell: Element | null = null; - const listen = () => { - shell = togglerTarget(document.documentElement, false); - shell?.addEventListener(STATE_EVENT, refresh); - refresh(); - }; - // The shell parses its element after its bundle, which may load after this page has run. - if (shellDocument?.readyState === 'loading') shellDocument.addEventListener('DOMContentLoaded', listen); - else listen(); - window.addEventListener('pagehide', () => { - shellDocument?.removeEventListener('DOMContentLoaded', listen); - shell?.removeEventListener(STATE_EVENT, refresh); - }); - window.addEventListener('pageshow', event => event.persisted && listen()); -} - /** * Registers the element under `tag` unless something is already registered there, and makes * elements with `data-devtools-anywhere-toggle` open and close it. @internal diff --git a/packages/devtools-anywhere/src/element/panel.ts b/packages/devtools-anywhere/src/element/panel.ts new file mode 100644 index 0000000..47170a4 --- /dev/null +++ b/packages/devtools-anywhere/src/element/panel.ts @@ -0,0 +1,183 @@ +// The panel's size and place, the user's: docked, its size along the page; undocked, its +// position and size, dragged by the bar and the corner. Remembered in localStorage as percentages +// of the viewport, so they fit any screen and orientation. +import { Grip } from '../grip'; +import { finite } from './request'; +import type { DockSide } from '../embedder'; + +const HEIGHT_KEY = 'devtools-anywhere:height%'; +const WIDTH_KEY = 'devtools-anywhere:width%'; +const FLOAT_KEY = 'devtools-anywhere:float%'; +/** Viewport units that follow the browser's collapsing toolbars on mobile, where supported. */ +const VH = typeof CSS !== 'undefined' && CSS.supports('height', '1dvh') ? 'dvh' : 'vh'; +const VW = typeof CSS !== 'undefined' && CSS.supports('width', '1dvw') ? 'dvw' : 'vw'; +/** `part` as a percentage of `whole`, to one decimal. */ +const percent = (part: number, whole: number) => Math.round((part / whole) * 1000) / 10; +export const DOCK_KEY = 'devtools-anywhere:dock'; +const MIN_SIZE = 80; +/** The panel's inner edge, next to the page, for each docked side. */ +export const GRIP_EDGE = { bottom: 'top', right: 'left', left: 'right' } as const; + +/** + * localStorage, which throws when it is unavailable: Android WebViews without DOM storage, + * Safari blocking all cookies, sandboxed iframes. Sizes and dock side are only preferences. + */ +export function readStored(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} +export function store(key: string, value: string) { + try { + localStorage.setItem(key, value); + } catch {} +} + +/** Position and size of the undocked panel, in percent of the viewport. */ +interface FloatRect { + left: number; + top: number; + width: number; + height: number; +} + +/** Sizes the panel for its dock side, from what is remembered. */ +export function sizePanel(panel: HTMLElement, dock: DockSide) { + const style = panel.style; + style.width = style.height = style.left = style.top = ''; + if (dock === 'bottom') { + const stored = Number(readStored(HEIGHT_KEY)); + style.height = stored ? `${stored}${VH}` : `50${VH}`; + } else if (dock === 'undocked') { + const rect = floatRect(); + style.left = `${rect.left}${VW}`; + style.top = `${rect.top}${VH}`; + style.width = `${rect.width}${VW}`; + style.height = `${rect.height}${VH}`; + } else { + const stored = Number(readStored(WIDTH_KEY)); + style.width = stored ? `${stored}${VW}` : `50${VW}`; + } +} + +/** The undocked panel's last position and size, kept inside the viewport. */ +function floatRect(): FloatRect { + let stored: unknown = null; + try { + stored = JSON.parse(readStored(FLOAT_KEY) || 'null'); + } catch {} + // Written by an older version or by the page itself: only finite numbers are used. + const field = (key: keyof FloatRect) => + typeof stored === 'object' && stored ? finite(Reflect.get(stored, key)) : undefined; + const width = Math.min(field('width') || 80, 100); + const height = Math.min(field('height') || 60, 100); + const clamp = (value: number | undefined, fallback: number, max: number) => + Math.min(Math.max(value ?? fallback, 0), max); + return { + left: clamp(field('left'), (100 - width) / 2, 100 - width), + top: clamp(field('top'), (100 - height) / 2, 100 - height), + width, + height, + }; +} + +/** Remembers the undocked panel's position and size. */ +export function saveFloat(panel: HTMLElement) { + const { left, top, width, height } = panel.getBoundingClientRect(); + const rect: FloatRect = { + left: percent(left, innerWidth), + top: percent(top, innerHeight), + width: percent(width, innerWidth), + height: percent(height, innerHeight), + }; + store(FLOAT_KEY, JSON.stringify(rect)); +} + +/** + * Dragging the bar moves the undocked panel; `relayout` then goes from the pixels of the drag to + * the remembered percentages. + */ +export function setupMover(bar: HTMLElement, panel: HTMLElement, relayout: () => void) { + bar.addEventListener('pointerdown', down => { + if ((down.target as HTMLElement).closest('button')) return; + down.preventDefault(); + bar.setPointerCapture(down.pointerId); + panel.classList.add('dragging'); + const start = panel.getBoundingClientRect(); + const move = (e: PointerEvent) => { + const left = start.left + e.clientX - down.clientX; + const top = start.top + e.clientY - down.clientY; + // The whole window stays in the viewport, so its resize corner stays reachable; a + // window larger than the viewport keeps its top left corner in view. + panel.style.left = `${Math.max(Math.min(left, innerWidth - start.width), 0)}px`; + panel.style.top = `${Math.max(Math.min(top, innerHeight - start.height), 0)}px`; + }; + const up = () => { + panel.classList.remove('dragging'); + saveFloat(panel); + relayout(); + bar.removeEventListener('pointermove', move); + bar.removeEventListener('pointerup', up); + bar.removeEventListener('pointercancel', up); + }; + bar.addEventListener('pointermove', move); + bar.addEventListener('pointerup', up); + bar.addEventListener('pointercancel', up); + }); +} + +/** + * The grip on the docked panel's inner edge, on the page's side: dragging it resizes the panel. + * `relayout` then goes from the pixels of the drag to the remembered percentages. + */ +export function createPanelGrip(panel: HTMLElement, dock: () => DockSide, relayout: () => void) { + const clampSize = (size: number, max: number) => Math.min(Math.max(size, MIN_SIZE), max - 16); + return new Grip({ + edge: 'top', + side: 'outside', + title: 'Drag to resize', + onStart: () => panel.classList.add('dragging'), + onMove: e => { + const style = panel.style; + const side = dock(); + if (side === 'bottom') style.height = `${clampSize(innerHeight - e.clientY, innerHeight)}px`; + else if (side === 'right') style.width = `${clampSize(innerWidth - e.clientX, innerWidth)}px`; + else if (side === 'left') style.width = `${clampSize(e.clientX, innerWidth)}px`; + }, + onEnd: () => { + panel.classList.remove('dragging'); + if (dock() === 'bottom') store(HEIGHT_KEY, String(percent(panel.offsetHeight, innerHeight))); + else store(WIDTH_KEY, String(percent(panel.offsetWidth, innerWidth))); + relayout(); + }, + }); +} + +/** Dragging the corner resizes the undocked panel, within the viewport. */ +export function setupCorner(corner: HTMLElement, panel: HTMLElement, relayout: () => void) { + corner.addEventListener('pointerdown', down => { + down.preventDefault(); + corner.setPointerCapture(down.pointerId); + panel.classList.add('dragging'); + const start = panel.getBoundingClientRect(); + const move = (e: PointerEvent) => { + const width = start.width + e.clientX - down.clientX; + const height = start.height + e.clientY - down.clientY; + panel.style.width = `${Math.min(Math.max(width, MIN_SIZE), innerWidth - start.left)}px`; + panel.style.height = `${Math.min(Math.max(height, MIN_SIZE), innerHeight - start.top)}px`; + }; + const up = () => { + panel.classList.remove('dragging'); + saveFloat(panel); + relayout(); + corner.removeEventListener('pointermove', move); + corner.removeEventListener('pointerup', up); + corner.removeEventListener('pointercancel', up); + }; + corner.addEventListener('pointermove', move); + corner.addEventListener('pointerup', up); + corner.addEventListener('pointercancel', up); + }); +} diff --git a/packages/devtools-anywhere/src/element/registry.ts b/packages/devtools-anywhere/src/element/registry.ts new file mode 100644 index 0000000..6717985 --- /dev/null +++ b/packages/devtools-anywhere/src/element/registry.ts @@ -0,0 +1,49 @@ +// Which `` is which: the probe's (global), the shell's, its F12 button in the +// shell's page, or one placed by the page; and the events between them and their togglers. +import { isShellDocument } from '../cdp-agent'; +import type { GlobalElementConfig } from '../api'; + +/** Every element of this window's class, whatever tag it is registered under. */ +export const ownElements = new WeakSet(); +/** + * The global elements, created for the probe (createGlobalElement), whether one has connected, and + * those connected after it, which do nothing. + */ +export const globalElements = new WeakSet(); +export const inactiveGlobals = new WeakSet(); +/** The connected elements placed by the page, each with what loads its frontend. */ +export const customElementsLoading = new Map void>(); +/** The probe's options for each global element, taken when it first connects. */ +export const globalConfigs = new WeakMap(); + +/** + * Whether a global element has connected; and the probe's frontend URL, for the elements placed by + * the page without one. + */ +export const globals = { taken: false, probeFrontend: '' }; + +/** The F12 buttons of the shell's element, in the shell's page (mountShellToggler). */ +export const shellTogglers = new WeakSet(); + +/** Whether `el` is an element placed by the page (of this window). */ +export const isCustom = (el: Element) => + ownElements.has(el) && !globalElements.has(el) && !shellTogglers.has(el) && !isShellElement(el); +/** + * Whether `el` is the shell's element: one in the shell document the probe wrote (not created + * for the probe), maybe of another window. Its document is marked as the shell's when the probe writes the + * shell; a `shell` attribute no longer makes an element the shell's. + */ +export const isShellElement = (el: Element) => !globalElements.has(el) && isShellDocument(el.ownerDocument); + +/** + * Fired on the global element when it opens or closes, for the togglers of the shell's page: an + * internal convention, not part of the API, which has only their aria-expanded. A page of the + * same origin can fire it too: it is not meant to be trusted. + */ +export const STATE_EVENT = 'devtools-anywhere-state'; + +/** + * The event a toggler controls its element with (see controlElement): an internal convention, not + * part of the API. A page of the same origin can fire it too: it is not meant to be trusted. + */ +export const CONTROL_EVENT = 'devtools-anywhere-control'; diff --git a/packages/devtools-anywhere/src/element/request.ts b/packages/devtools-anywhere/src/element/request.ts new file mode 100644 index 0000000..b140454 --- /dev/null +++ b/packages/devtools-anywhere/src/element/request.ts @@ -0,0 +1,29 @@ +// Requests from the frontend (embedder.ts), checked: it is built separately, and any window can post. +import { DOCK_SIDES, type DockSide, type EmbedderRequest } from '../embedder'; + +/** A finite number from a stored value, else undefined. */ +export const finite = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined); + +export const isDockSide = (value: unknown): value is DockSide => DOCK_SIDES.some(side => side === value); + +/** + * Checks the fields of each type of request from the frontend, which is built separately: a type + * added to EmbedderRequest (embedder.ts) does not compile here until it is checked. + */ +const REQUEST_CHECKS: { [T in EmbedderRequest['type']]: (request: object) => boolean } = { + dock: request => 'side' in request && isDockSide(request.side), + close: () => true, + 'keep-open': request => 'keep' in request && typeof request.keep === 'boolean', + theme: request => ['toolbar', 'divider', 'icon'].every(key => typeof Reflect.get(request, key) === 'string'), + 'page-bounds': request => ['x', 'y', 'width', 'height'].every(key => finite(Reflect.get(request, key)) !== undefined), + split: request => ['x', 'y', 'width', 'height'].every(key => finite(Reflect.get(request, key)) !== undefined), + toolbox: request => 'url' in request && typeof request.url === 'string', +}; +const isRequestType = (type: unknown): type is EmbedderRequest['type'] => + typeof type === 'string' && Object.hasOwn(REQUEST_CHECKS, type); +export const isEmbedderRequest = (value: unknown): value is EmbedderRequest => + typeof value === 'object' && + value !== null && + 'type' in value && + isRequestType(value.type) && + REQUEST_CHECKS[value.type](value); diff --git a/packages/devtools-anywhere/src/element/style.ts b/packages/devtools-anywhere/src/element/style.ts new file mode 100644 index 0000000..49a8651 --- /dev/null +++ b/packages/devtools-anywhere/src/element/style.ts @@ -0,0 +1,94 @@ +/** The shadow root style of ``. */ +export const STYLE = ` +:host { all: initial; display: block; } +:host([hidden]) { display: none; } +/* Both go into the top layer as popovers (see TOP_LAYER): undo the browser's popover style. */ +.toggler, .panel { + inset: auto; width: auto; height: auto; margin: 0; padding: 0; border: 0; overflow: visible; color: inherit; +} +.toggler { + position: fixed; z-index: 2147483647; + right: calc(16px + env(safe-area-inset-right, 0px)); bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + width: 44px; height: 44px; border: 0; border-radius: 50%; + background: #1a73e8; color: #fff; font: 600 13px/44px system-ui, sans-serif; + box-shadow: 0 2px 6px rgba(0, 0, 0, .3); cursor: pointer; touch-action: manipulation; +} +.toggler[hidden] { display: none; } +.panel { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483647; + display: none; flex-direction: column; box-sizing: border-box; + background: #fff; border-top: 1px solid var(--bar-border, #cacdd1); + /* Clear of the screen's cutouts and rounded corners (a page with viewport-fit=cover has them): + the home indicator below, and the notch on the sides in landscape. */ + padding: 0 env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); +} +.panel[data-open] { display: flex; } +/* The undocked window's bar, which moves it; it takes the DevTools toolbar's colors, which the + frontend reports (theme request), as does the grip that resizes the docked panel (grip.ts). */ +.bar { + flex: none; position: relative; height: 16px; background: var(--bar-background, #f1f3f4); + cursor: move; touch-action: none; display: none; +} +[data-dock=undocked] .bar { display: block; } +.bar::before { + content: ''; position: absolute; left: 50%; top: 6px; width: 32px; height: 4px; + margin-left: -16px; border-radius: 2px; background: var(--bar-handle, #bdc1c6); +} +.bar button { + position: absolute; top: 0; width: 24px; height: 16px; padding: 0; border: 0; + background: none; color: var(--bar-icon, #5f6368); font: 14px/16px system-ui, sans-serif; cursor: pointer; +} +/* DevTools' own toolbar has a close button, except when undocked: then the bar has one. */ +.close { right: 4px; } +/* The undocked window's resize corner, for touch screens where CSS resize does not work. */ +.corner { + position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; display: none; + cursor: nwse-resize; touch-action: none; +} +[data-dock=undocked] .corner { display: block; } +iframe { flex: 1; min-width: 0; min-height: 0; border: 0; } +.dragging iframe { pointer-events: none; } + +/* Docked to a side: full height, with the bar as a vertical strip on the inner edge. */ +.panel[data-dock=right], .panel[data-dock=left] { + top: 0; border-top: 0; padding-top: env(safe-area-inset-top, 0px); +} +/* No inset on the inner edge, which is in the middle of the screen. */ +.panel[data-dock=right] { + left: auto; flex-direction: row; border-left: 1px solid var(--bar-border, #cacdd1); padding-left: 0; +} +.panel[data-dock=left] { + right: auto; flex-direction: row-reverse; border-right: 1px solid var(--bar-border, #cacdd1); padding-right: 0; +} + +/* Undocked: a window over the page, moved by its bar and resized from its corner. */ +.panel[data-dock=undocked] { + right: auto; bottom: auto; border: 1px solid var(--bar-border, #cacdd1); border-radius: 6px; overflow: hidden; + box-shadow: 0 4px 16px rgba(0, 0, 0, .3); resize: both; padding: 0; +} + +/* Touch screens: targets large enough for a finger. */ +@media (pointer: coarse) { + .bar { height: 28px; } + .bar::before { top: 12px; } + .bar button { width: 44px; height: 28px; line-height: 28px; font-size: 18px; } + .corner { width: 32px; height: 32px; } +} + +/* An element placed by the page: a box in the page's flow, sized by its CSS, always loaded. */ +.panel[data-custom] { + position: static; display: flex; width: 100%; height: 100%; border: 0; padding: 0; +} +[data-custom] .bar, .toggler[data-custom] { display: none; } + +/* Shell mode: the panel takes its place next to the page iframe, unless undocked. */ +:host([data-shell]) { display: flex; flex: none; } +:host([data-shell]) .panel { position: static; flex: 1 1 auto; } +:host([data-shell]) .panel[data-dock=undocked] { position: fixed; } +/* Laid out by the frontend (shell-layout.ts): it fills the shell, the page iframe on top. */ +:host([data-laid-out]) { position: fixed; inset: 0; z-index: 0; } +:host([data-laid-out]) .panel { + position: absolute; inset: 0; width: auto !important; height: auto !important; border: 0; +} +:host([data-laid-out]) .bar { display: none; } +`; diff --git a/packages/devtools-anywhere/src/element/togglers.ts b/packages/devtools-anywhere/src/element/togglers.ts new file mode 100644 index 0000000..32de278 --- /dev/null +++ b/packages/devtools-anywhere/src/element/togglers.ts @@ -0,0 +1,128 @@ +// Elements with `data-devtools-anywhere-toggle` open and close a `` when clicked. +import { isShellPage } from '../cdp-agent'; +import type { TogglerAttributes } from '../public-types'; +import { + CONTROL_EVENT, + customElementsLoading, + globalElements, + inactiveGlobals, + isCustom, + isShellElement, + ownElements, + STATE_EVENT, +} from './registry'; + +/** What a toggler fires on an element placed by the page, which has no open state. */ +const TOGGLE_REQUEST_EVENT = 'devtools-toggle-request'; + +/** Elements with this attribute open and close DevTools when clicked (see togglerTarget). */ +const TOGGLE_ATTRIBUTE: keyof TogglerAttributes = 'data-devtools-anywhere-toggle'; +/** The tag the togglers look for, once they are installed. */ +let togglerTag = ''; +/** Toggler values already warned about. */ +const warned = new Set(); + +/** + * The element a toggler controls: the element whose id is the attribute's value, or without one + * the first global element, or in the shell's page iframe the shell's element. + */ +export function togglerTarget(toggler: Element, warn: boolean): Element | null { + const id = toggler.getAttribute(TOGGLE_ATTRIBUTE) ?? ''; + const doc = toggler.ownerDocument; + let target: Element | null | undefined = null; + if (isShellPage()) { + // An element the page placed in the shell's page: the toggler names it. Without a value, the + // shell's element, of the shell window's class: it is toggled through an event. + const named = id ? doc.getElementById(id) : null; + if (named && customElementsLoading.has(named)) return named; + if (!id) return shellElement(); + } else { + // Without a value, the global element (or the shell's); global elements after the first do + // nothing: they are not targets. + target = id + ? doc.getElementById(id) + : [...doc.querySelectorAll(togglerTag)].find( + el => (globalElements.has(el) || isShellElement(el)) && !inactiveGlobals.has(el) + ); + } + if (target && ownElements.has(target) && !inactiveGlobals.has(target)) return target; + if (warn && !warned.has(id)) { + warned.add(id); + console.warn(`devtools-anywhere: no <${togglerTag}>${id ? ` with id "${id}"` : ''} for ${TOGGLE_ATTRIBUTE}`); + } + return null; +} + +/** In the shell's page iframe: the shell's element, of the shell window's class. */ +export function shellElement(): Element | null { + const shellDocument = window.frameElement?.ownerDocument; + return (shellDocument && [...shellDocument.querySelectorAll(togglerTag)].find(isShellElement)) ?? null; +} + +/** + * Asks a toggler's element, maybe of another window, whether it is open, toggling it first when + * `toggle`. Undefined when it does not answer. + */ +export function controlElement(target: Element, toggle: boolean): boolean | undefined { + const detail: { toggle: boolean; open?: boolean } = { toggle }; + target.dispatchEvent(new CustomEvent(CONTROL_EVENT, { detail })); + return detail.open; +} + +/** Sets aria-expanded on a toggler from the state of its element. */ +function refreshToggler(toggler: Element, warn = false) { + const target = togglerTarget(toggler, warn); + // An element placed by the page has no open state. + if (target && isCustom(target)) return target; + const open = target && controlElement(target, false); + if (open !== undefined && open !== null) toggler.setAttribute('aria-expanded', String(open)); + return target; +} + +export function refreshTogglers(doc: Document) { + if (!togglerTag) return; + for (const toggler of doc.querySelectorAll(`[${TOGGLE_ATTRIBUTE}]`)) refreshToggler(toggler); +} + +/** Makes any element with TOGGLE_ATTRIBUTE, including those added later, a toggler. */ +export function installTogglers(tag: string) { + if (togglerTag) return; + togglerTag = tag; + document.addEventListener( + 'click', + event => { + const clicked = event.target instanceof Element ? event.target : null; + const toggler = clicked?.closest(`[${TOGGLE_ATTRIBUTE}]`); + // A label's click is followed by one on its control: that one toggles, once. + const control = clicked?.closest('label')?.control; + if (!toggler || (control && control !== clicked && toggler.contains(control))) return; + const target = toggler && refreshToggler(toggler, true); + if (!target) return; + // An element placed by the page only tells its listeners, which decide. + if (isCustom(target)) target.dispatchEvent(new Event(TOGGLE_REQUEST_EVENT)); + else controlElement(target, true); + refreshToggler(toggler); + }, + true + ); + const refresh = () => refreshTogglers(document); + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', refresh); + else refresh(); + // In the shell's page iframe, the togglers follow the shell's element while this page is shown. + if (!isShellPage()) return; + const shellDocument = window.frameElement?.ownerDocument; + let shell: Element | null = null; + const listen = () => { + shell = togglerTarget(document.documentElement, false); + shell?.addEventListener(STATE_EVENT, refresh); + refresh(); + }; + // The shell parses its element after its bundle, which may load after this page has run. + if (shellDocument?.readyState === 'loading') shellDocument.addEventListener('DOMContentLoaded', listen); + else listen(); + window.addEventListener('pagehide', () => { + shellDocument?.removeEventListener('DOMContentLoaded', listen); + shell?.removeEventListener(STATE_EVENT, refresh); + }); + window.addEventListener('pageshow', event => event.persisted && listen()); +} diff --git a/packages/devtools-anywhere/src/embedder.ts b/packages/devtools-anywhere/src/embedder.ts index c0d8bd6..bb58938 100644 --- a/packages/devtools-anywhere/src/embedder.ts +++ b/packages/devtools-anywhere/src/embedder.ts @@ -13,13 +13,32 @@ export type DockSide = (typeof DOCK_SIDES)[number]; /** * Requests from the frontend to the embedder: its dock side menu, its close button, keeping - * DevTools open across reloads (shell mode), and the theme's colors for the panel's bar. + * DevTools open across reloads (shell mode), the theme's colors for the panel's bar, and in shell + * mode where the page goes (`page-bounds`, the frontend's InspectedPagePlaceholder, in CSS pixels + * of the frontend's viewport, like Chrome's setInspectedPageBounds) and the page's side of the split + * next to DevTools (`split`, in the same pixels: the whole of it, where the page bounds are only the + * device's screen in device mode), for the shell's grip on DevTools' edge. + * Undocked, device mode draws into a page of the frontend's (`toolbox`, its URL), which Chrome + * opens in the tab under DevTools' window and the shell shows in an iframe under the panel, where + * the frontend finds it among its parent's frames; the page bounds are then in CSS pixels of that + * iframe. */ export type EmbedderRequest = | { type: 'dock'; side: DockSide } | { type: 'close' } | { type: 'keep-open'; keep: boolean } - | { type: 'theme'; toolbar: string; divider: string; icon: string }; + | { type: 'theme'; toolbar: string; divider: string; icon: string } + | ({ type: 'page-bounds' } & PageBounds) + | ({ type: 'split' } & PageBounds) + | { type: 'toolbox'; url: string }; + +/** A rectangle in CSS pixels. */ +export interface PageBounds { + x: number; + y: number; + width: number; + height: number; +} /** * Tells the frontend, posted to it under {@link REQUEST_KEY} (`{ type: HIDDEN_NOTICE }`), that the @@ -27,10 +46,13 @@ export type EmbedderRequest = * highlights, which would otherwise keep taking the page over. */ export const HIDDEN_NOTICE = 'hidden'; -/** What the embedder posts to the frontend: {@link HIDDEN_NOTICE}. */ -export interface FrontendNotice { - type: typeof HIDDEN_NOTICE; -} +/** + * Tells the frontend, in shell mode, to give DevTools `size` CSS pixels next to the page: the + * shell's touch handle on the edge between them was dragged. + */ +export const SIDEBAR_SIZE_NOTICE = 'sidebar-size'; +/** What the embedder posts to the frontend: {@link HIDDEN_NOTICE} or {@link SIDEBAR_SIZE_NOTICE}. */ +export type FrontendNotice = { type: typeof HIDDEN_NOTICE } | { type: typeof SIDEBAR_SIZE_NOTICE; size: number }; /** * The key a request is posted under (`{ [REQUEST_KEY]: request }`): requests are objects, so diff --git a/packages/devtools-anywhere/src/grip.ts b/packages/devtools-anywhere/src/grip.ts new file mode 100644 index 0000000..e6daf87 --- /dev/null +++ b/packages/devtools-anywhere/src/grip.ts @@ -0,0 +1,119 @@ +// A grip for dragging a divider: a small pill with three dots that takes input only where it is +// seen, so that it hides none of what is around it. It is placed against an edge of its parent +// (which must be a containing block, positioned or fixed), on the middle of that edge: across +// the edge, inside the parent or outside it. Its colors are the custom properties +// --grip-background, --grip-border and --grip-dot, inherited from where it is placed. + +/** An edge of the grip's parent. */ +export type Edge = 'top' | 'right' | 'bottom' | 'left'; + +/** Where the grip sits against the edge: across it, inside the parent, or outside it. */ +export type GripSide = 'center' | 'inside' | 'outside'; + +/** A size in CSS pixels: across the edge, then along it. */ +export type GripSize = readonly [across: number, along: number]; + +export interface GripOptions { + edge: Edge; + side?: GripSide; + /** For a mouse, and larger for a finger on touch screens. */ + size?: { mouse: GripSize; touch: GripSize }; + /** Its tooltip. */ + title?: string; + onStart?(down: PointerEvent): void; + /** Each move of the pointer while the grip is dragged; `down` is where the drag started. */ + onMove(move: PointerEvent, down: PointerEvent): void; + onEnd?(): void; +} + +const DEFAULT_SIZE = { mouse: [12, 36], touch: [20, 52] } as const; +const OPPOSITE: Record = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }; +/** The corners on each side, in border-radius order (top left, top right, bottom right, bottom left). */ +const CORNERS: Record = { top: [0, 1], right: [1, 2], bottom: [2, 3], left: [3, 0] }; +const COARSE = '(pointer: coarse)'; + +export class Grip { + readonly element: HTMLDivElement; + private edge: Edge; + private side: GripSide; + private readonly size: { mouse: GripSize; touch: GripSize }; + private readonly coarse = matchMedia(COARSE); + private readonly onPointerChange = () => this.render(); + + constructor(private readonly options: GripOptions) { + this.edge = options.edge; + this.side = options.side ?? 'center'; + this.size = options.size ?? DEFAULT_SIZE; + const el = (this.element = document.createElement('div')); + if (options.title) el.title = options.title; + el.style.cssText = + 'position:absolute;z-index:1;display:none;box-sizing:border-box;touch-action:none;' + + 'align-items:center;justify-content:center;gap:3px;' + + 'background:var(--grip-background,#f1f3f4);border:1px solid var(--grip-border,#cacdd1)'; + for (let i = 0; i < 3; i++) { + const dot = document.createElement('span'); + dot.style.cssText = + 'width:3px;height:3px;border-radius:50%;background:var(--grip-dot,#5f6368);pointer-events:none'; + el.append(dot); + } + this.coarse.addEventListener('change', this.onPointerChange); + this.setupDrag(); + this.render(); + } + + /** Moves the grip to another edge of its parent, or another side of it. */ + place(edge: Edge, side: GripSide = this.side) { + this.edge = edge; + this.side = side; + this.render(); + } + + show(shown: boolean) { + this.element.style.display = shown ? 'flex' : 'none'; + } + + dispose() { + this.coarse.removeEventListener('change', this.onPointerChange); + this.element.remove(); + } + + private render() { + const { edge, side } = this; + const style = this.element.style; + const [across, along] = this.size[this.coarse.matches ? 'touch' : 'mouse']; + const horizontal = edge === 'top' || edge === 'bottom'; + style.cursor = horizontal ? 'row-resize' : 'col-resize'; + style.flexDirection = horizontal ? 'row' : 'column'; + style.width = `${horizontal ? along : across}px`; + style.height = `${horizontal ? across : along}px`; + // On the middle of the edge, and across it, inside or outside by its offset from the edge. + style.top = style.right = style.bottom = style.left = ''; + style[horizontal ? 'left' : 'top'] = `calc(50% - ${along / 2}px)`; + style[edge] = `${{ center: -across / 2, inside: 0, outside: -across }[side]}px`; + // Against the edge, a side of the grip is flat and has no border; the others are rounded. + const flat: Edge | null = side === 'inside' ? edge : side === 'outside' ? OPPOSITE[edge] : null; + const radius = [6, 6, 6, 6]; + if (flat) for (const corner of CORNERS[flat]) radius[corner] = 0; + style.borderRadius = radius.map(r => `${r}px`).join(' '); + style.borderWidth = '1px'; + if (flat) style.setProperty(`border-${flat}-width`, '0'); + } + + private setupDrag() { + const el = this.element; + el.addEventListener('pointerdown', down => { + down.preventDefault(); + el.setPointerCapture(down.pointerId); + this.options.onStart?.(down); + const move = (e: PointerEvent) => this.options.onMove(e, down); + // Released, or the capture lost (the grip removed mid-drag): a single end either way. + const end = () => { + el.removeEventListener('pointermove', move); + el.removeEventListener('lostpointercapture', end); + this.options.onEnd?.(); + }; + el.addEventListener('pointermove', move); + el.addEventListener('lostpointercapture', end); + }); + } +} diff --git a/packages/devtools-anywhere/src/iife.ts b/packages/devtools-anywhere/src/iife.ts index 2ee5b69..cc120e4 100644 --- a/packages/devtools-anywhere/src/iife.ts +++ b/packages/devtools-anywhere/src/iife.ts @@ -19,7 +19,7 @@ import { connectRemote } from './remote'; // again itself: only the first copy runs. if (!('DevtoolsAnywhere' in window)) { const script = document.currentScript as HTMLScriptElement | null; - // Shell configs must come from this script's origin. + // Shell configs may only point where this script trusts (trust.ts). if (script?.src) setShellScriptUrl(script.src); const probe = installProbe(); if (script?.src) { diff --git a/packages/devtools-anywhere/src/probe-script.ts b/packages/devtools-anywhere/src/probe-script.ts index 3eb2ca8..ebe7355 100644 --- a/packages/devtools-anywhere/src/probe-script.ts +++ b/packages/devtools-anywhere/src/probe-script.ts @@ -5,8 +5,14 @@ * @public */ export type ProbeScriptAttributes = { - /** The frontend to mount with, after the page has loaded. */ + /** + * The frontend to mount with, after the page has loaded. By default, with the + * probe from jsDelivr or unpkg, the frontend package of the same version there; otherwise none, + * and no element. + */ 'data-devtools-frontend-url'?: string; + /** No element (and no shell mode), whatever the frontend: for a page only debugged remotely. */ + 'data-no-panel'?: true; /** Where devtools-anywhere.iife.js is, by default next to the probe. */ 'data-bundle'?: string; /** Opens DevTools right away. */ diff --git a/packages/devtools-anywhere/src/probe.ts b/packages/devtools-anywhere/src/probe.ts index 4ec3c3f..50efe25 100644 --- a/packages/devtools-anywhere/src/probe.ts +++ b/packages/devtools-anywhere/src/probe.ts @@ -2,6 +2,8 @@ // // // +// From jsDelivr or unpkg, `data-devtools-frontend-url` defaults to the frontend package of the same +// version there (trust.ts); `data-no-panel` goes without it, for a page only debugged remotely. // As the first script in , it captures from the very start. With `data-devtools-frontend-url`, it // loads devtools-anywhere.iife.js (from `data-bundle`, by default next to this file) after // the page has loaded and mounts its own under , whatever the page has. @@ -33,6 +35,7 @@ // in probe.iife.js) and `shell` query parameters, and kept while it answers that a shell in // another tab still uses it. The page's own workers are left alone. import { + cdnFrontendUrl, SHELL_KEY, SHELL_MODE_KEY, SHELL_PAGE_ID, @@ -49,12 +52,16 @@ import type { ProbeScriptAttributes } from './probe-script'; import { SHELL_QUERY, SHELL_REMOTE } from './shell-messages'; const script = document.currentScript as HTMLScriptElement | null; -// Shell configs must come from this script's origin. +// Shell configs may only point where this script trusts (trust.ts). if (script?.src) setShellScriptUrl(script.src); /** One of the script's attributes (probe-script.ts); `null` when it is absent. */ const attribute = (name: keyof ProbeScriptAttributes) => script?.getAttribute(name) ?? null; -const frontend = attribute('data-devtools-frontend-url'); +// Loaded from an npm CDN, the probe finds the frontend of its version there by default. +const frontend = + attribute('data-no-panel') === null + ? attribute('data-devtools-frontend-url') || (script?.src ? cdnFrontendUrl(script.src) : null) + : null; const remote = attribute('data-remote'); // A probe inlined, without a URL, has no bundle next to it. const bundle = script?.src ? new URL(attribute('data-bundle') || BUNDLE_SCRIPT, script.src).href : null; @@ -173,7 +180,7 @@ const hasApi = ( // In the shell's top window the probe has replaced the document with the shell already. The page in // the shell has the bundle from installProbe() already and only connects to the remote server: the shell -// shows DevTools, and the elements the page places take the shell's frontend (see element.ts). +// shows DevTools, and the elements the page places take the shell's frontend (see element/index.ts). if (script && bundle && (frontend || remoteUrl || shellPage) && !shellTop) { probe.ownUrls.add(bundle); diff --git a/packages/devtools-anywhere/src/shell-layout.ts b/packages/devtools-anywhere/src/shell-layout.ts new file mode 100644 index 0000000..8d6694a --- /dev/null +++ b/packages/devtools-anywhere/src/shell-layout.ts @@ -0,0 +1,208 @@ +// Shell mode's layout, as in Chrome: DevTools' frontend fills the shell and lays out the page and +// itself (upstream's AdvancedApp), telling where the page goes (`page-bounds`, its +// InspectedPagePlaceholder); the shell places the page iframe there, on top. In device mode the +// frontend asks for the page's size through Emulation.setDeviceMetricsOverride, which the shell +// answers by sizing and scaling the page iframe. Undocked, device mode draws into a page of the +// frontend's (`toolbox`), shown here in an iframe under the floating panel; the bounds are then in +// that iframe. Until the frontend says where the page goes, or while DevTools is hidden, the page +// fills the shell. Docked, a grip on the page's side of the middle of the edge between the page +// and DevTools resizes them, since the frontend's own resizer is a few pixels wide and shows +// nothing; it covers none of DevTools, and only as much of the page as it shows. + +import type { DeviceMetrics } from './connection'; +import { Grip } from './grip'; +import { type DockSide, type FrontendNotice, type PageBounds, REQUEST_KEY, SIDEBAR_SIZE_NOTICE } from './embedder'; + +/** Sets the given inline styles, and clears the others this layout sets. */ +function place( + el: HTMLElement, + styles: Partial> +) { + const style = el.style; + const fixed = Object.keys(styles).length > 0; + style.position = fixed ? 'fixed' : ''; + style.zIndex = fixed ? '1' : ''; + style.left = styles.left ?? ''; + style.top = styles.top ?? ''; + style.width = styles.width ?? ''; + style.height = styles.height ?? ''; + style.flex = fixed ? 'none' : ''; + style.transform = styles.transform ?? ''; + style.transformOrigin = styles.transform ? '0 0' : ''; + style.clipPath = styles.clipPath ?? ''; +} + +export class ShellLayout { + private bounds: PageBounds | null = null; + /** The page's side of the split next to DevTools (`split`), where the grip goes. */ + private split: PageBounds | null = null; + private metrics: DeviceMetrics | null = null; + private toolbox: HTMLIFrameElement | null = null; + private open = false; + private dock: DockSide = 'bottom'; + /** The page's side of the split next to DevTools, for the grip on DevTools' edge (grip.ts). */ + private readonly pageArea: HTMLDivElement; + private readonly grip: Grip; + + /** + * @param host the shell's ``, which fills the shell while the frontend lays out + * @param frontend the frontend's iframe + * @param page the page's iframe + * @param frontendOrigin where notices to the frontend go + */ + constructor( + private readonly host: HTMLElement, + private readonly frontend: HTMLIFrameElement, + private readonly page: HTMLIFrameElement, + private readonly frontendOrigin: () => string + ) { + this.pageArea = document.createElement('div'); + this.pageArea.style.cssText = 'position:fixed;z-index:2;display:none;pointer-events:none'; + this.grip = new Grip({ + edge: 'bottom', + side: 'inside', + title: 'Drag to resize', + onMove: e => this.resizeTo(e), + }); + this.grip.element.style.pointerEvents = 'auto'; + this.grip.show(true); + this.pageArea.append(this.grip.element); + document.body.append(this.pageArea); + addEventListener('resize', this.onResize); + } + + private readonly onResize = () => this.render(); + + /** The element left the shell: stops listening and removes what this layout added. */ + dispose() { + removeEventListener('resize', this.onResize); + this.grip.dispose(); + this.pageArea.remove(); + this.removeToolbox(); + // Not left at the device's size, or scaled: the page fills the document again. + place(this.page, {}); + } + + /** DevTools was shown or hidden, or docked elsewhere. */ + update(open: boolean, dock: DockSide) { + if (dock !== this.dock) { + // The bounds were in the other side's layout (or in the toolbox): wait for new ones. + this.bounds = null; + this.split = null; + if (dock !== 'undocked') this.removeToolbox(); + } + this.open = open; + this.dock = dock; + this.render(); + } + + pageBounds(bounds: PageBounds) { + this.bounds = bounds; + this.render(); + } + + splitArea(area: PageBounds) { + this.split = area; + this.render(); + } + + /** Emulation.setDeviceMetricsOverride, or (null) clearDeviceMetricsOverride. */ + setMetrics(metrics: DeviceMetrics | null) { + this.metrics = metrics; + this.render(); + } + + /** + * Shows device mode's page for the undocked panel. The frontend asks for it as it undocks, + * before it says it did. + */ + showToolbox(url: string) { + this.removeToolbox(); + const frame = document.createElement('iframe'); + frame.title = 'Device mode'; + frame.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;z-index:0'; + frame.src = url; + document.body.append(frame); + this.toolbox = frame; + this.render(); + } + + private removeToolbox() { + this.toolbox?.remove(); + this.toolbox = null; + } + + /** Where the frontend lays out the page: its own iframe, or undocked the toolbox's. */ + private get origin(): HTMLIFrameElement | null { + return this.dock === 'undocked' ? this.toolbox : this.frontend; + } + + private render() { + const origin = this.origin; + const laidOut = this.open && !!this.bounds && !!origin; + // Docked, the frontend fills the shell from the start, so that it lays out at its full size + // (device mode does not recover from starting small); undocked, it stays a floating panel. + this.host.toggleAttribute('data-laid-out', this.open && this.dock !== 'undocked'); + if (this.toolbox) this.toolbox.style.display = this.open ? '' : 'none'; + this.renderGrip(); + // Until then the page fills the shell, on top. + if (!laidOut || !this.bounds || !origin) + return place(this.page, this.open ? { left: '0', top: '0', width: '100%', height: '100%' } : {}); + const frame = origin.getBoundingClientRect(); + const { x, y, width, height } = this.bounds; + const scale = this.metrics?.scale || 1; + // The page's own size; the bounds are what shows of it once scaled. + const pageWidth = this.metrics?.width || width / scale; + const pageHeight = this.metrics?.height || height / scale; + // What does not fit in the bounds is clipped; a rounding error of the bounds is not. + const hidden = (size: number) => (size >= 1 ? size : 0); + const hiddenRight = hidden(pageWidth - width / scale); + const hiddenBottom = hidden(pageHeight - height / scale); + place(this.page, { + left: `${frame.left + x}px`, + top: `${frame.top + y}px`, + width: `${pageWidth}px`, + height: `${pageHeight}px`, + transform: scale === 1 ? '' : `scale(${scale})`, + clipPath: hiddenRight || hiddenBottom ? `inset(0 ${hiddenRight}px ${hiddenBottom}px 0)` : '', + }); + } + + /** + * The grip sits against the middle of the page's edge next to DevTools, on the page's side, as + * a tab of DevTools' panel; docked only. + */ + private renderGrip() { + // Not the page bounds: in device mode they are only the device's screen, in the middle. + const area = this.split; + const shown = this.open && this.dock !== 'undocked' && !!area && !!this.bounds; + this.pageArea.style.display = shown ? 'block' : 'none'; + if (!shown || !area || this.dock === 'undocked') return; + const frame = this.frontend.getBoundingClientRect(); + Object.assign(this.pageArea.style, { + left: `${frame.left + area.x}px`, + top: `${frame.top + area.y}px`, + width: `${area.width}px`, + height: `${area.height}px`, + }); + this.grip.place(this.dock); + } + + /** The grip's colors (its custom properties, grip.ts), DevTools' toolbar's. */ + setGripColors(colors: Record) { + for (const [property, value] of Object.entries(colors)) this.pageArea.style.setProperty(property, value); + } + + /** Dragging the grip: DevTools takes the room from the pointer to its far edge. */ + private resizeTo(e: PointerEvent) { + const frame = this.frontend.getBoundingClientRect(); + const size = + this.dock === 'bottom' + ? frame.bottom - e.clientY + : this.dock === 'right' + ? frame.right - e.clientX + : e.clientX - frame.left; + const notice: FrontendNotice = { type: SIDEBAR_SIZE_NOTICE, size: Math.round(size) }; + this.frontend.contentWindow?.postMessage({ [REQUEST_KEY]: notice }, this.frontendOrigin()); + } +} diff --git a/packages/devtools-anywhere/src/shell-messages.ts b/packages/devtools-anywhere/src/shell-messages.ts index aa33565..4205e00 100644 --- a/packages/devtools-anywhere/src/shell-messages.ts +++ b/packages/devtools-anywhere/src/shell-messages.ts @@ -1,5 +1,5 @@ // Messages of shell mode between the page and its Service Worker (devtools-anywhere-service-worker.js), which -// service-worker.ts, element.ts and probe.ts all use. Constants only: service-worker.ts is built for a worker. +// service-worker.ts, element/index.ts and probe.ts all use. Constants only: service-worker.ts is built for a worker. /** Posted by the shell to the worker: the client it comes from is a shell. */ export const SHELL_HANDSHAKE = 'devtools-anywhere:shell-handshake'; diff --git a/packages/devtools-frontend/patches/device-mode-in-shell.patch b/packages/devtools-frontend/patches/device-mode-in-shell.patch new file mode 100644 index 0000000..8cd9679 --- /dev/null +++ b/packages/devtools-frontend/patches/device-mode-in-shell.patch @@ -0,0 +1,134 @@ +diff --git a/front_end/core/root/Runtime.ts b/front_end/core/root/Runtime.ts +index 0c94af63c6..156d0b7818 100644 +--- a/front_end/core/root/Runtime.ts ++++ b/front_end/core/root/Runtime.ts +@@ -640,6 +640,9 @@ export type Condition = (config?: HostConfig) => boolean; + + export const conditions: { + canDock: Condition, ++ canEmulateDevice: Condition, + } = { + canDock: () => Boolean(Runtime.queryParam('can_dock')), ++ // Device mode needs an embedder that can resize the inspected page (devtools-anywhere's shell). ++ canEmulateDevice: () => Boolean(Runtime.queryParam('can_emulate_device')), + }; +diff --git a/front_end/panels/emulation/AdvancedApp.ts b/front_end/panels/emulation/AdvancedApp.ts +index 199ae25afc..e47adfc537 100644 +--- a/front_end/panels/emulation/AdvancedApp.ts ++++ b/front_end/panels/emulation/AdvancedApp.ts +@@ -83,7 +83,8 @@ export class AdvancedApp implements UI.App.App { + return; + } + +- const url = window.location.href.replace('devtools_app.html', 'device_mode_emulation_frame.html'); ++ // Next to the app's page, whichever it is (devtools-anywhere's anywhere_app.html). ++ const url = new URL('device_mode_emulation_frame.html', window.location.href).href; + this.toolboxWindow = window.open(url, undefined); + } + +diff --git a/front_end/panels/emulation/DeviceModeToolbar.ts b/front_end/panels/emulation/DeviceModeToolbar.ts +index aca819ebfb..a10c9d0e6a 100644 +--- a/front_end/panels/emulation/DeviceModeToolbar.ts ++++ b/front_end/panels/emulation/DeviceModeToolbar.ts +@@ -801,12 +801,8 @@ export class DeviceModeToolbar extends UI.Widget.Widget { + appendToggleItem( + contextMenu.headerSection(), this.showRulersSetting, i18nString(UIStrings.hideRulers), + i18nString(UIStrings.showRulers), undefined, 'rulers'); +- appendToggleItem( +- contextMenu.defaultSection(), this.showDeviceScaleFactorSetting, i18nString(UIStrings.removeDevicePixelRatio), +- i18nString(UIStrings.addDevicePixelRatio), undefined, 'device-pixel-ratio'); +- appendToggleItem( +- contextMenu.defaultSection(), this.showUserAgentTypeSetting, i18nString(UIStrings.removeDeviceType), +- i18nString(UIStrings.addDeviceType), undefined, 'device-type'); ++ // No device pixel ratio or device type: a page cannot have its devicePixelRatio or its ++ // user agent changed (devtools-anywhere), so neither is offered. + contextMenu.appendItemsAtLocation('deviceModeMenu'); + contextMenu.footerSection().appendItem( + i18nString(UIStrings.resetToDefaults), this.reset.bind(this), {jslogContext: 'reset-to-defaults'}); +diff --git a/front_end/panels/emulation/emulation-meta.ts b/front_end/panels/emulation/emulation-meta.ts +index 40bd5bb1ca..ce94de9d03 100644 +--- a/front_end/panels/emulation/emulation-meta.ts ++++ b/front_end/panels/emulation/emulation-meta.ts +@@ -73,7 +73,7 @@ UI.ActionRegistration.registerActionExtension({ + const Emulation = await loadEmulationModule(); + return new Emulation.DeviceModeView.ActionDelegate(); + }, +- condition: Root.Runtime.conditions.canDock, ++ condition: Root.Runtime.conditions.canEmulateDevice, + title: i18nLazyString(UIStrings.toggleDeviceToolbar), + iconClass: UI.ActionRegistration.IconClass.LARGEICON_PHONE, + bindings: [ +@@ -95,7 +95,7 @@ UI.ActionRegistration.registerActionExtension({ + const Emulation = await loadEmulationModule(); + return new Emulation.DeviceModeView.ActionDelegate(); + }, +- condition: Root.Runtime.conditions.canDock, ++ condition: Root.Runtime.conditions.canEmulateDevice, + title: i18nLazyString(UIStrings.captureScreenshot), + }); + +@@ -106,7 +106,7 @@ UI.ActionRegistration.registerActionExtension({ + const Emulation = await loadEmulationModule(); + return new Emulation.DeviceModeView.ActionDelegate(); + }, +- condition: Root.Runtime.conditions.canDock, ++ condition: Root.Runtime.conditions.canEmulateDevice, + title: i18nLazyString(UIStrings.captureFullSizeScreenshot), + }); + +@@ -117,7 +117,7 @@ UI.ActionRegistration.registerActionExtension({ + const Emulation = await loadEmulationModule(); + return new Emulation.DeviceModeView.ActionDelegate(); + }, +- condition: Root.Runtime.conditions.canDock, ++ condition: Root.Runtime.conditions.canEmulateDevice, + title: i18nLazyString(UIStrings.captureNodeScreenshot), + }); + +@@ -159,7 +159,7 @@ Common.Settings.registerSettingExtension({ + + UI.Toolbar.registerToolbarItem({ + actionId: 'emulation.toggle-device-mode', +- condition: Root.Runtime.conditions.canDock, ++ condition: Root.Runtime.conditions.canEmulateDevice, + location: UI.Toolbar.ToolbarItemLocation.MAIN_TOOLBAR_LEFT, + order: 2, + }); +@@ -169,7 +169,7 @@ UI.AppProvider.registerAppProvider({ + const Emulation = await loadEmulationModule(); + return Emulation.AdvancedApp.AdvancedAppProvider.instance(); + }, +- condition: Root.Runtime.conditions.canDock, ++ condition: Root.Runtime.conditions.canEmulateDevice, + order: 0, + }); + +diff --git a/front_end/panels/screencast/screencast-meta.ts b/front_end/panels/screencast/screencast-meta.ts +index b8abbb9d6b..c499ab4f54 100644 +--- a/front_end/panels/screencast/screencast-meta.ts ++++ b/front_end/panels/screencast/screencast-meta.ts +@@ -2,6 +2,7 @@ + // Use of this source code is governed by a BSD-style license that can be + // found in the LICENSE file. + ++import * as Root from '../../core/root/root.js'; + import * as UI from '../../ui/legacy/legacy.js'; + + import type * as Screencast from './screencast.js'; +@@ -22,6 +23,8 @@ UI.Toolbar.registerToolbarItem({ + }, + order: 1, + location: UI.Toolbar.ToolbarItemLocation.MAIN_TOOLBAR_LEFT, ++ // Device mode shows the page itself, and takes the toolbar's device button. ++ condition: () => !Root.Runtime.conditions.canEmulateDevice(), + }); + + UI.AppProvider.registerAppProvider({ +@@ -29,6 +32,7 @@ UI.AppProvider.registerAppProvider({ + const Screencast = await loadScreencastModule(); + return Screencast.ScreencastApp.ScreencastAppProvider.instance(); + }, ++ condition: () => !Root.Runtime.conditions.canEmulateDevice(), + order: 1, + }); + diff --git a/packages/devtools-frontend/scripts/build.ts b/packages/devtools-frontend/scripts/build.ts index 3da32b1..e889c53 100644 --- a/packages/devtools-frontend/scripts/build.ts +++ b/packages/devtools-frontend/scripts/build.ts @@ -250,7 +250,8 @@ if (!args.has('--copy-only')) { // The release files (the ones Chrome ships, listed in devtools_resources.grd) serve every // DevTools app. Only what anywhere_app can load is kept: the modules reachable from its // entrypoint through imports and `new URL(…, import.meta.url)`, the assets next to them, -// and the UI strings of those modules. +// and the UI strings of those modules. Device mode, when undocked, draws its toolbar into the +// page device_mode_emulation_frame.html, which the shell shows in an iframe: it is kept too. const gen = path.join(out, 'gen/front_end'); const grd = fs.readFileSync(path.join(gen, 'devtools_resources.grd'), 'utf8'); const files = [...grd.matchAll(/]*file="([^"]+)"/g)] @@ -259,7 +260,8 @@ const files = [...grd.matchAll(/]*file="([^"]+)"/g)] const released = new Set(files); const reachable = new Set(); -const queue = ['entrypoints/anywhere_app/anywhere_app.js']; +const PAGES = ['anywhere_app.html', 'device_mode_emulation_frame.html']; +const queue = PAGES.map(page => `entrypoints/${page.replace('.html', '')}/${page.replace('.html', '.js')}`); for (let file; (file = queue.pop());) { if (reachable.has(file) || !released.has(file)) continue; reachable.add(file); @@ -292,7 +294,7 @@ const OWN_STRINGS = 'entrypoints/anywhere_app/anywhere_app.ts'; } const keep = (file: string) => { if (file.endsWith('.js')) return reachable.has(file); - if (file.endsWith('.html')) return file === 'anywhere_app.html'; + if (file.endsWith('.html')) return PAGES.includes(file); if (file.startsWith(LOCALES)) return false; // written below return !file.includes('/') || file.startsWith('Images/') || inModule(file); }; diff --git a/packages/devtools-frontend/scripts/bundle.ts b/packages/devtools-frontend/scripts/bundle.ts index 550810c..bf4696b 100644 --- a/packages/devtools-frontend/scripts/bundle.ts +++ b/packages/devtools-frontend/scripts/bundle.ts @@ -4,7 +4,8 @@ // - each module loaded with import() (a panel, mostly) is a chunk with what only it needs, // loaded when it is; the entry, what several of them share, and the ones too small to be // worth a request are the chunk DevTools loads when it opens; -// - each worker is a single file of its own; +// - each worker is a single file of its own, and so is device mode's page for when DevTools is +// undocked (device_mode_emulation_frame.html, which the shell shows in an iframe); // - the SVG icons are inlined into Images.js as data: URLs (each declared once, as the CSS // variable every use reads); // - every bundled file is named after a hash of its content, so it can be cached for good: @@ -22,6 +23,9 @@ import { transform } from 'esbuild'; const OUT_DIR = 'bundle'; const APP = 'entrypoints/anywhere_app/anywhere_app.js'; +/** Device mode's page for when DevTools is undocked, and the module it loads. */ +const TOOLBOX_PAGE = 'device_mode_emulation_frame.html'; +const TOOLBOX = 'entrypoints/device_mode_emulation_frame/device_mode_emulation_frame.js'; const WORKERS = [ 'entrypoints/formatter_worker/formatter_worker-entrypoint.js', 'entrypoints/wasmparser_worker/wasmparser_worker-entrypoint.js', @@ -213,6 +217,7 @@ export async function bundle(src: string, out: string): Promise { // The workers first: the app names their files. for (const worker of WORKERS) workerOutput.set(worker, `./${await write(worker, false)}`); const app = await write(APP, true); + const toolbox = await write(TOOLBOX, false); if (!legacyImport) throw new Error('bundle: Root.Runtime#loadLegacyModule not found (upstream changed?)'); // The text of the bundle and of the other text files, to tell whether an SVG is still named. @@ -239,9 +244,15 @@ export async function bundle(src: string, out: string): Promise { const dir = thirdPartyDir(name); if (dir) thirdParty.add(dir); } - const html = path.join(out, 'anywhere_app.html'); - const page = fs.readFileSync(html, 'utf8'); - if (!page.includes(`./${APP}`)) throw new Error(`bundle: anywhere_app.html does not load ./${APP}`); - fs.writeFileSync(html, page.replace(`./${APP}`, `./${OUT_DIR}/${app}`)); + const pages: [file: string, module: string, bundled: string][] = [ + ['anywhere_app.html', APP, app], + [TOOLBOX_PAGE, TOOLBOX, toolbox], + ]; + for (const [file, module, bundled] of pages) { + const html = path.join(out, file); + const page = fs.readFileSync(html, 'utf8'); + if (!page.includes(`./${module}`)) throw new Error(`bundle: ${file} does not load ./${module}`); + fs.writeFileSync(html, page.replace(`./${module}`, `./${OUT_DIR}/${bundled}`)); + } return [...thirdParty].sort(); } diff --git a/packages/devtools-frontend/src/anywhere_app/BUILD.gn b/packages/devtools-frontend/src/anywhere_app/BUILD.gn index 989e5ce..97a4190 100644 --- a/packages/devtools-frontend/src/anywhere_app/BUILD.gn +++ b/packages/devtools-frontend/src/anywhere_app/BUILD.gn @@ -26,6 +26,7 @@ devtools_entrypoint("entrypoint") { "../../panels/console:meta", "../../panels/console_counters:meta", "../../panels/elements:meta", + "../../panels/emulation:meta", "../../panels/mobile_throttling:meta", "../../panels/network:meta", "../../panels/screencast:meta", diff --git a/packages/devtools-frontend/src/anywhere_app/anywhere_app.ts b/packages/devtools-frontend/src/anywhere_app/anywhere_app.ts index eb56d99..558a963 100644 --- a/packages/devtools-frontend/src/anywhere_app/anywhere_app.ts +++ b/packages/devtools-frontend/src/anywhere_app/anywhere_app.ts @@ -19,6 +19,8 @@ import '../../panels/mobile_throttling/mobile_throttling-meta.js'; import '../../panels/application/application-meta.js'; // The page as an image, with input forwarded to it: for when the page is not in view. import '../../panels/screencast/screencast-meta.js'; +// Device mode, in shell mode only (`can_emulate_device`): there the screencast is left out. +import '../../panels/emulation/emulation-meta.js'; import '../../panels/settings/settings-meta.js'; import '../../ui/legacy/components/perf_ui/perf_ui-meta.js'; import '../../ui/legacy/components/quick_open/quick_open-meta.js'; @@ -38,6 +40,7 @@ import { type FrontendHash, HIDDEN_NOTICE, OWN_SCRIPTS, + SIDEBAR_SIZE_NOTICE, type RemoteFrontendHash, REQUEST_KEY, } from './embedder.js'; @@ -165,6 +168,80 @@ if (!remote) { Root.Runtime.Runtime.setQueryParamForTesting('can_pause', 'false'); const dock = param('dock'); +// Shell mode: the page is in the shell's other iframe, which the shell places where this frontend +// says (Chrome's setInspectedPageBounds), and resizes for device mode. DevTools then lays out the +// page and itself as in Chrome (upstream's AdvancedApp, which device mode needs). +if (!remote && param('shell') === '1') { + Root.Runtime.Runtime.setQueryParamForTesting('can_emulate_device', 'true'); +} +// Undocked, upstream's AdvancedApp opens device mode's page in a window (Chrome shows it in the +// tab, under DevTools' window), which hands it its document through window.opener. The shell +// shows the page in an iframe instead, a sibling of this frontend's (same origin): once loaded, +// its document is handed over from here. +const TOOLBOX_PAGE = 'device_mode_emulation_frame.html'; +const isToolbox = (url: string): boolean => new URL(url, location.href).pathname.endsWith(`/${TOOLBOX_PAGE}`); +/** The shell's iframe of device mode's page, once loaded. */ +function loadedToolbox(): Document|null { + for (let i = 0; i < window.parent.frames.length; ++i) { + try { + const frame = window.parent.frames[i]; + if (isToolbox(frame.location.href) && frame.document.readyState === 'complete') { + return frame.document; + } + } catch { + // Another origin: the page. + } + } + return null; +} +/** The latest request for the toolbox: an earlier one stops looking for it. */ +let toolboxRequest = 0; +function handOverToolbox(request: number, deadline: number): void { + if (request !== toolboxRequest) { + return; + } + const toolbox = loadedToolbox(); + if (toolbox) { + useDefaultTheme(toolbox); + Reflect.get(globalThis, 'Emulation')?.AdvancedApp?.instance().deviceModeEmulationFrameLoaded(toolbox); + } else if (Date.now() < deadline) { + setTimeout(() => handOverToolbox(request, deadline), 100); + } else { + console.warn(`Device mode's page (${TOOLBOX_PAGE}) did not load in the shell`); + } +} +const open = window.open.bind(window); +window.open = (url?: string|URL, target?: string, features?: string) => { + if (!remote && url !== undefined && isToolbox(String(url))) { + notifyEmbedder({type: 'toolbox', url: new URL(url, location.href).href}); + handOverToolbox(++toolboxRequest, Date.now() + 60000); + return null; + } + return open(url, target, features); +}; +let splitWatched = false; +/** The page's side of the split, next to DevTools, for the shell's grip on DevTools' edge. */ +function notifySplit(split: UI.SplitWidget.SplitWidget): void { + const main = split.mainWidget()?.element.getBoundingClientRect(); + if (main) { + notifyEmbedder({type: 'split', x: main.x, y: main.y, width: main.width, height: main.height}); + } +} +host.setInspectedPageBounds = bounds => { + notifyEmbedder({type: 'page-bounds', ...bounds}); + const split = UI.InspectorView.InspectorView.maybeGetInspectorViewInstance()?.ownerSplit(); + if (!split) { + return; + } + notifySplit(split); + if (!splitWatched) { + splitWatched = true; + // Measured once laid out at the new size. + split.addEventListener( + UI.SplitWidget.Events.SIDEBAR_SIZE_CHANGED, () => requestAnimationFrame(() => notifySplit(split))); + } +}; + // devtools-anywhere's own scripts (the probe, the bundle, snapDOM) are listed in Sources, since // they hook globals the page uses, but ignore-listed like extension scripts in Chrome: their stack // frames collapse, and "Hide ignore-listed sources" hides them. The rule is added once, so the @@ -326,8 +403,15 @@ window.addEventListener('message', event => { if (typeof event.data !== 'string') { const data: unknown = event.data; const notice: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, REQUEST_KEY) : null; - if (typeof notice === 'object' && notice !== null && Reflect.get(notice, 'type') === HIDDEN_NOTICE) { + if (typeof notice !== 'object' || notice === null) { + return; + } + const type: unknown = Reflect.get(notice, 'type'); + const size: unknown = Reflect.get(notice, 'size'); + if (type === HIDDEN_NOTICE) { onHidden(); + } else if (type === SIDEBAR_SIZE_NOTICE && typeof size === 'number' && Number.isFinite(size)) { + UI.InspectorView.InspectorView.maybeGetInspectorViewInstance()?.ownerSplit()?.setSidebarSize(size); } return; } @@ -339,21 +423,26 @@ window.addEventListener('message', event => { // dynamic theme (blue-tinted surfaces). Here the page names Chrome's default theme itself and // the request is reported as loaded, which gives Chrome's default look: the palette's // fallbacks are its colors. -const defaultTheme = document.createElement('style'); -defaultTheme.textContent = ':root { --user-color-source: baseline-default; }'; -document.head.append(defaultTheme); -// The link is added once, at startup: watching stops then, not to run on every node DevTools adds. -const themeColorsObserver = new MutationObserver(records => { - for (const record of records) { - for (const node of record.addedNodes) { - if (node instanceof HTMLLinkElement && node.href.startsWith('devtools://theme/colors.css')) { - node.dispatchEvent(new Event('load')); - themeColorsObserver.disconnect(); +// Device mode's page for when DevTools is undocked gets the same. +function useDefaultTheme(doc: Document): void { + const defaultTheme = doc.createElement('style'); + defaultTheme.textContent = ':root { --user-color-source: baseline-default; }'; + doc.head.append(defaultTheme); + // The link is added once, at startup: watching stops then, not to run on every node DevTools adds. + const themeColorsObserver = new MutationObserver(records => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node instanceof (doc.defaultView ?? window).HTMLLinkElement && + node.href.startsWith('devtools://theme/colors.css')) { + node.dispatchEvent(new Event('load')); + themeColorsObserver.disconnect(); + } } } - } -}); -themeColorsObserver.observe(document, {childList: true, subtree: true}); + }); + themeColorsObserver.observe(doc, {childList: true, subtree: true}); +} +useDefaultTheme(document); // The embedder's bar sits on top of the toolbar: it takes the toolbar's colors, sent whenever // the theme is applied (which sets classes on the root element). diff --git a/packages/remote-server/README.md b/packages/remote-server/README.md index f00b66b..1dcabfc 100644 --- a/packages/remote-server/README.md +++ b/packages/remote-server/README.md @@ -6,4 +6,4 @@ A relay that lets Chrome DevTools on one machine inspect a page running on anoth npx @devtools-anywhere/remote-server --port 8080 ``` -Options, the page side and security: [Remote debugging](https://github.com/jerrychan7/devtools#remote-debugging) ([中文](https://github.com/jerrychan7/devtools/blob/main/README.zh-CN.md#远程调试)). Requires Node.js 22.18 or later. MIT licensed. +Options, the page side and security: [Remote debugging](https://github.com/jerrychan7/devtools/blob/main/docs/usage.md#remote-debugging) ([中文](https://github.com/jerrychan7/devtools/blob/main/docs/usage.zh-CN.md#远程调试)). Requires Node.js 22.18 or later. MIT licensed. diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index a565bfe..118c184 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -8,4 +8,4 @@ import devtoolsAnywhere from '@devtools-anywhere/vite-plugin'; export default { plugins: [devtoolsAnywhere()] }; ``` -Options: [Vite](https://github.com/jerrychan7/devtools#vite) ([中文](https://github.com/jerrychan7/devtools/blob/main/README.zh-CN.md#vite)). Requires Node.js 22.18 or later. MIT licensed. +Options: [Vite](https://github.com/jerrychan7/devtools/blob/main/docs/usage.md#vite) ([中文](https://github.com/jerrychan7/devtools/blob/main/docs/usage.zh-CN.md#vite)). Requires Node.js 22.18 or later. MIT licensed. diff --git a/tests/e2e/cdn.test.ts b/tests/e2e/cdn.test.ts new file mode 100644 index 0000000..40c1016 --- /dev/null +++ b/tests/e2e/cdn.test.ts @@ -0,0 +1,133 @@ +// The probe from an npm CDN (jsDelivr): one script tag, with the frontend of the same version found +// there, shell mode included; and a shell config pointing elsewhere on the CDN is not trusted. The +// CDN is played by the static server, through request interception: the probe's package from the +// build, the frontend's entry page from the fake frontend. +import { existsSync } from 'node:fs'; +import { after, before, beforeEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { HTTPRequest, Page } from 'puppeteer-core'; +import { launch } from '../support/browser.ts'; +import type { Env } from '../support/browser.ts'; + +const CDN = 'https://cdn.jsdelivr.net/npm/'; +const PROBE = `${CDN}devtools-anywhere@9.9.9/dist/probe.iife.js`; +const FRONTEND = `${CDN}@devtools-anywhere/devtools-frontend@9.9.9/dist/anywhere_app.html`; + +let env: Env | undefined; +let page: Page; +let url = ''; + +before(async () => { + env = await launch(); + url = env.baseUrl + '/cdn.html'; +}); +after(() => env?.close()); +beforeEach(async () => { + await page?.close(); + assert.ok(env); + page = await env.browser.newPage(); +}); + +const built = existsSync(new URL('../../packages/devtools-frontend/dist/anywhere_app.html', import.meta.url)); + +/** Serves the page at `url`, with the probe's script tag given these attributes, and the CDN. */ +async function serve(attributes = '', frontend: 'fake' | 'built' = 'fake') { + const base = env?.baseUrl ?? ''; + const files: [string, string][] = [ + [`${CDN}devtools-anywhere@9.9.9/`, `${base}/packages/devtools-anywhere/`], + frontend === 'fake' + ? [FRONTEND, `${base}/tests/e2e/frontends/fake-frontend.html`] + : [`${CDN}@devtools-anywhere/devtools-frontend@9.9.9/`, `${base}/packages/devtools-frontend/`], + [`${CDN}evil@1.0.0/anywhere_app.html`, `${base}/tests/e2e/frontends/fake-frontend.html`], + ]; + const html = ` + + +

cdn

`; + await page.setRequestInterception(true); + page.on('request', (r: HTTPRequest) => { + const at = r.url(); + if (at === url) return void r.respond({ status: 200, contentType: 'text/html', body: html }); + const file = files.find(([prefix]) => at.startsWith(prefix)); + if (!file) return void r.continue(); + // Cross-origin, as from the CDN. + void fetch(file[1] + at.slice(file[0].length)) + .then(async response => + r.respond({ + status: response.status, + contentType: response.headers.get('content-type') ?? '', + headers: { 'access-control-allow-origin': '*' }, + body: Buffer.from(await response.arrayBuffer()), + }) + ) + // A request that fails, rather than one never answered. + .catch(() => r.abort()); + }); +} + +/** The URL of the frontend iframe of the global element, once there. */ +const frontendSrc = () => + page + .waitForFunction(() => { + const frame = document.querySelector('devtools-anywhere')?.shadowRoot?.querySelector('iframe'); + return frame?.src || null; + }) + .then(handle => handle.jsonValue()); + +test('one script tag: the frontend of the same version on the CDN, in shell mode', async () => { + await serve('data-open-immediately'); + await page.goto(url); + await page.waitForFunction(() => document.querySelector('devtools-anywhere[data-shell]')); + const src = new URL((await frontendSrc()) ?? ''); + assert.equal(src.origin + src.pathname, FRONTEND); + const config = await page.evaluate(() => sessionStorage.getItem('devtools-anywhere:shell')); + assert.equal(JSON.parse(config ?? '{}').frontend, FRONTEND); +}); + +test('a shell config pointing to another package on the CDN is removed and ignored', async () => { + await serve('data-default-shell-mode="off"'); + await page.goto(url); + await page.waitForFunction(() => window.__pageScriptRan === true); + const evil = `${CDN}evil@1.0.0/anywhere_app.html`; + await page.evaluate( + (frontend, bundle) => + sessionStorage.setItem('devtools-anywhere:shell', JSON.stringify({ frontend, bundle, open: true })), + evil, + `${CDN}devtools-anywhere@9.9.9/dist/devtools-anywhere.iife.js` + ); + await page.reload(); + // The probe ran: its own element, out of shell mode. + await page.waitForFunction(() => document.querySelector('devtools-anywhere')); + assert.equal(await page.evaluate(() => !!document.querySelector('devtools-anywhere[data-shell]')), false); + assert.equal(await page.evaluate(() => sessionStorage.getItem('devtools-anywhere:shell')), null); +}); + +test('data-no-panel: no element, and no shell mode', async () => { + // Only debugged remotely, at a server that is not there. + await serve('data-no-panel data-remote="http://127.0.0.1:9/"'); + await page.goto(url); + // The bundle has loaded, and would have mounted the element as it did. + await page.waitForFunction(() => 'DevtoolsAnywhere' in window); + assert.equal(await page.evaluate(() => document.querySelectorAll('devtools-anywhere').length), 0); + assert.equal(await page.evaluate(() => sessionStorage.getItem('devtools-anywhere:shell')), null); +}); + +test( + 'the built frontend from the CDN lays out the page in shell mode', + { skip: built ? false : 'run `pnpm --filter @devtools-anywhere/devtools-frontend build:frontend` first' }, + async () => { + await page.setViewport({ width: 1000, height: 800 }); + await serve('data-open-immediately', 'built'); + await page.goto(url); + // The frontend, on another origin, says where the page goes: above DevTools, docked at the bottom. + await page.waitForFunction( + () => { + const frame = document.getElementById('devtools-anywhere-page'); + if (!(frame instanceof HTMLIFrameElement)) return false; + const { top, height } = frame.getBoundingClientRect(); + return top === 0 && height > 100 && height < innerHeight - 100; + }, + { timeout: 30000 } + ); + } +); diff --git a/tests/e2e/device-mode.test.ts b/tests/e2e/device-mode.test.ts new file mode 100644 index 0000000..62faea4 --- /dev/null +++ b/tests/e2e/device-mode.test.ts @@ -0,0 +1,388 @@ +// Device mode in shell mode, with the DevTools frontend built from the official sources: the +// frontend lays out the page and itself as in Chrome (shell-layout.ts), and device mode sizes the +// page. Skipped when the frontend has not been built. The tests run in order, each from where the +// previous one left the page. +import { existsSync } from 'node:fs'; +import { after, before, test } from 'node:test'; +import assert from 'node:assert/strict'; +import type { Frame, Page } from 'puppeteer-core'; +import { launch } from '../support/browser.ts'; +import type { Env } from '../support/browser.ts'; + +const built = existsSync(new URL('../../packages/devtools-frontend/dist/anywhere_app.html', import.meta.url)); +const skip = built ? false : 'run `pnpm --filter @devtools-anywhere/devtools-frontend build:frontend` first'; + +let env: Env | undefined; +let page: Page; +/** Uncaught errors in the shell, the frontend and the toolbox. */ +const errors: string[] = []; + +before(async () => { + if (!built) return; + env = await launch(); + page = await env.browser.newPage(); + page.on('pageerror', error => errors.push(error instanceof Error ? error.message : String(error))); + await page.setViewport({ width: 1000, height: 800 }); + await page.goto(env.baseUrl + '/tests/e2e/pages/device-mode.html'); +}); +after(() => env?.close()); + +async function waitFor(get: () => T | Promise, what = 'condition', timeout = 15000): Promise> { + const start = Date.now(); + for (;;) { + const value = await Promise.resolve() + .then(get) + .catch(() => null); + if (value) return value; + if (Date.now() - start > timeout) throw new Error(`timed out waiting for ${what}`); + await new Promise(r => setTimeout(r, 100)); + } +} + +const frontend = () => waitFor(() => page.frames().find(f => f.url().includes('anywhere_app.html')), 'the frontend'); + +/** Runs an action of the frontend's (UI.ActionRegistry), as its toolbar button or menu item would. */ +async function runAction(id: string) { + await ( + await frontend() + ).evaluate(async id => { + const script = document.querySelector('script[type=module]')?.src ?? ''; + // The built frontend has no type declarations; only this much of it is used. + const UI: { + ActionRegistry: { ActionRegistry: { instance(): { getAction(id: string): { execute(): Promise } } } }; + } = await (await import(script)).modules['ui/legacy/legacy.js'](); + await UI.ActionRegistry.ActionRegistry.instance().getAction(id).execute(); + }, id); +} + +/** Moves DevTools to a dock side, as the main menu's "Dock side" does. */ +async function dock(side: string) { + await ( + await frontend() + ).evaluate(async side => { + const script = document.querySelector('script[type=module]')?.src ?? ''; + const UI: { DockController: { DockController: { instance(): { setDockSide(side: string): void } } } } = await ( + await import(script) + ).modules['ui/legacy/legacy.js'](); + UI.DockController.DockController.instance().setDockSide(side); + }, side); +} + +/** Picks a device in device mode's toolbar, in `frame` (the frontend, or undocked the toolbox). */ +async function pickDevice(frame: Frame, name: string) { + await frame.evaluate(name => { + const find = (root: Document | ShadowRoot): HTMLSelectElement | null => { + const found = root.querySelector('select[aria-label="Device type"]'); + if (found) return found; + for (const el of root.querySelectorAll('*')) { + const inShadow = el.shadowRoot && find(el.shadowRoot); + if (inShadow) return inShadow; + } + return null; + }; + const select = find(document); + if (!select) throw new Error('no device select'); + const index = [...select.options].findIndex(o => o.text === name); + if (index < 0) throw new Error(`no device ${name}`); + select.selectedIndex = index; + select.dispatchEvent(new Event('change', { bubbles: true })); + }, name); +} + +/** Where the page iframe is, and the page's viewport. */ +const pageState = () => + page.evaluate(() => { + const iframe = document.getElementById('devtools-anywhere-page'); + if (!(iframe instanceof HTMLIFrameElement) || !iframe.contentWindow) throw new Error('no page iframe'); + const { left, top, width, height } = iframe.getBoundingClientRect(); + return { + rect: { left, top, width, height }, + viewport: { width: iframe.contentWindow.innerWidth, height: iframe.contentWindow.innerHeight }, + }; + }); + +/** + * The frontend's InspectedPagePlaceholder, where it lays out the page, in the shell's coordinates: + * in `origin`, the frontend's frame or undocked the toolbox's. + */ +const placeholder = async (origin: Frame) => { + const rect = await ( + await frontend() + ).evaluate(() => { + // The built frontend has no type declarations: AdvancedApp's placeholder, a private field. + const app: unknown = Reflect.get(globalThis, 'Emulation')?.AdvancedApp?.instance(); + const el: unknown = app && Reflect.get(Reflect.get(app, 'inspectedPagePlaceholder'), 'element'); + if (!(el instanceof Element)) throw new Error('no placeholder'); + const { left, top, width, height } = el.getBoundingClientRect(); + return { left, top, width, height }; + }); + const offset = await ( + await origin.frameElement() + )?.evaluate(f => { + const { left, top } = f.getBoundingClientRect(); + return { left, top }; + }); + return { ...rect, left: rect.left + (offset?.left ?? 0), top: rect.top + (offset?.top ?? 0) }; +}; + +const near = (a: number, b: number, what: string) => assert.ok(Math.abs(a - b) <= 2, `${what}: ${a} vs ${b}`); + +test('in shell mode the frontend fills the shell and places the page, as in Chrome', { skip }, async () => { + const fe = await frontend(); + const frame = await waitFor(async () => { + const rect = await (await fe.frameElement())?.evaluate(f => f.getBoundingClientRect().toJSON()); + return rect && rect.width === 1000 && rect.height === 800 ? rect : null; + }); + assert.ok(frame); + // The page goes where the frontend's placeholder is, on top of it. + const where = await waitFor(async () => { + const [state, spot] = [await pageState(), await placeholder(fe)]; + return state.rect.height < 800 && Math.abs(state.rect.top - spot.top) <= 2 ? { state, spot } : null; + }); + near(where.state.rect.width, where.spot.width, 'width'); + near(where.state.rect.height, where.spot.height, 'height'); + assert.equal(where.state.viewport.width, Math.round(where.state.rect.width)); + // The toolbar's device button is device mode's, not the screencast's. + const labels = await fe.evaluate(() => { + const out: string[] = []; + const walk = (root: Document | ShadowRoot) => { + for (const el of root.querySelectorAll('[aria-label]')) out.push(el.getAttribute('aria-label') ?? ''); + for (const el of root.querySelectorAll('*')) if (el.shadowRoot) walk(el.shadowRoot); + }; + walk(document); + return out; + }); + assert.ok(labels.includes('Toggle device toolbar'), 'device mode button'); + assert.ok(!labels.includes('Toggle screencast'), 'no screencast button'); +}); + +test('device mode sizes the page as the device, scaled to fit, and takes its screenshot', { skip }, async () => { + await runAction('emulation.toggle-device-mode'); + const fe = await frontend(); + await waitFor(() => pickDevice(fe, 'iPhone SE').then(() => true)); + const state = await waitFor(async () => { + const s = await pageState(); + return s.viewport.width === 375 && s.viewport.height === 667 ? s : null; + }); + // Scaled to fit the room the frontend gives it. + assert.ok(state.rect.height < 667, `scaled: ${state.rect.height}`); + near(state.rect.width / state.rect.height, 375 / 667, 'aspect'); + // The screenshot command saves the page at the device's size (at the page's own pixel ratio): + // the image it draws, and the link it clicks to download it (not followed here). + await fe.evaluate(() => { + const saved: { name?: string; width?: number; height?: number } = {}; + const convert = OffscreenCanvas.prototype.convertToBlob; + const click = HTMLAnchorElement.prototype.click; + Reflect.set(window, '__saved', saved); + Reflect.set(window, '__restore', () => { + OffscreenCanvas.prototype.convertToBlob = convert; + HTMLAnchorElement.prototype.click = click; + }); + OffscreenCanvas.prototype.convertToBlob = function (this: OffscreenCanvas, options) { + Object.assign(saved, { width: this.width, height: this.height }); + return convert.call(this, options); + }; + HTMLAnchorElement.prototype.click = function (this: HTMLAnchorElement) { + saved.name = this.download; + }; + }); + let saved: { name: string; width: number; height: number }; + try { + await runAction('emulation.capture-screenshot'); + saved = await waitFor( + () => + fe.evaluate(() => { + const value: { name?: string; width?: number; height?: number } = Reflect.get(window, '__saved'); + return value.name ? { name: value.name, width: value.width ?? 0, height: value.height ?? 0 } : null; + }), + 'the screenshot' + ); + } finally { + await fe.evaluate(() => { + const restore: unknown = Reflect.get(window, '__restore'); + if (typeof restore === 'function') restore(); + }); + } + assert.match(saved.name, /\(iPhone SE\)\.png$/); + assert.equal(saved.width, 375); + near(saved.height, 667, 'screenshot height'); +}); + +test('hidden, the page fills the shell at its own size; shown again, device mode is back', { skip }, async () => { + const toggle = () => + page.evaluate(() => + document + .querySelector('devtools-anywhere') + ?.dispatchEvent(new CustomEvent('devtools-anywhere-control', { detail: { toggle: true } })) + ); + await toggle(); + const hidden = await waitFor(async () => { + const s = await pageState(); + return s.viewport.width === 1000 && s.viewport.height === 800 ? s : null; + }); + assert.deepEqual(hidden.rect, { left: 0, top: 0, width: 1000, height: 800 }); + await toggle(); + await waitFor(async () => (await pageState()).viewport.width === 375); +}); + +test('undocked, device mode draws into its own page under the floating panel', { skip }, async () => { + await dock('undocked'); + try { + const toolbox = await waitFor(() => page.frames().find(f => f.url().includes('device_mode_emulation_frame.html'))); + // Its toolbar is there, and the page goes where it says. + // Another device than before, so that the page's size comes from this toolbar. + await waitFor(() => pickDevice(toolbox, 'iPhone 16').then(() => true), "the toolbox's toolbar"); + const where = await waitFor(async () => { + const [state, spot] = [await pageState(), await placeholder(toolbox).catch(() => null)]; + return spot && + state.viewport.width === 393 && + Math.abs(state.rect.left - spot.left) <= 2 && + Math.abs(state.rect.width - spot.width) <= 2 + ? { state, spot } + : null; + }, 'the page in the toolbox'); + near(where.state.rect.width, where.spot.width, 'width'); + } finally { + await dock('bottom'); + } + await waitFor(async () => !page.frames().some(f => f.url().includes('device_mode_emulation_frame.html'))); + await waitFor(async () => (await pageState()).rect.height < 800); +}); + +test('docked to the right, the page goes where the frontend says', { skip }, async () => { + await dock('right'); + const fe = await frontend(); + // The page left of DevTools, where the placeholder is. + try { + await waitFor(async () => { + const [state, spot] = [await pageState(), await placeholder(fe)]; + return ( + state.viewport.width === 393 && + spot.left < 500 && + Math.abs(state.rect.left - spot.left) <= 2 && + Math.abs(state.rect.top - spot.top) <= 2 + ); + }, 'the page docked right'); + } finally { + await dock('bottom'); + } +}); + +test("in device mode, the grip stays on DevTools' edge, not on the device's screen", { skip }, async () => { + const fe = await frontend(); + await waitFor(async () => { + // DevTools' top edge, docked at the bottom, in the shell's coordinates. + const edge = await fe.evaluate(async () => { + const script = document.querySelector('script[type=module]')?.src ?? ''; + // The built frontend has no type declarations: InspectorView's element. + const UI: { InspectorView: { InspectorView: { instance(): { element: Element } } } } = await ( + await import(script) + ).modules['ui/legacy/legacy.js'](); + return UI.InspectorView.InspectorView.instance().element.getBoundingClientRect().top; + }); + const offset = await (await fe.frameElement())?.evaluate(f => f.getBoundingClientRect().top); + const grip = await page.evaluate(() => { + const el = document.querySelector('body > div > div[title="Drag to resize"]'); + return el?.getClientRects().length ? el.getBoundingClientRect().bottom : null; + }); + return grip !== null && offset !== undefined && Math.abs(grip - (edge + offset)) <= 2; + }, "the grip on DevTools' edge"); +}); + +test('leaving device mode gives the page the room the frontend lays out for it', { skip }, async () => { + await runAction('emulation.toggle-device-mode'); + const fe = await frontend(); + const where = await waitFor(async () => { + const [state, spot] = [await pageState(), await placeholder(fe)]; + return state.viewport.width !== 393 && Math.abs(state.rect.width - spot.width) <= 2 ? { state, spot } : null; + }, 'the page at its own size'); + near(where.state.rect.height, where.spot.height, 'height'); + assert.equal(where.state.viewport.width, Math.round(where.state.rect.width)); +}); + +test( + "docked, a grip on the edge between the page and DevTools, on the page's side, resizes them", + { skip }, + async () => { + const fe = await frontend(); + const found = await waitFor(async () => { + const [spot, handle] = [ + await placeholder(fe), + await page.evaluate(() => { + const el = document.querySelector('body > div > div[title="Drag to resize"]'); + if (!el || !el.getClientRects().length) return null; + const { top, width, height } = el.getBoundingClientRect(); + return { top, width, height, dots: el.children.length }; + }), + ]; + // Docked at the bottom, the edge is the bottom of the page's area: the grip sits on it, above. + return handle && Math.abs(handle.top + handle.height - (spot.top + spot.height)) <= 2 ? handle : null; + }, 'the handle on the edge'); + // For a mouse, a grip of 36 × 12 pixels with three dots: it takes input only where it is seen. + assert.deepEqual( + { width: found.width, height: found.height, dots: found.dots }, + { width: 36, height: 12, dots: 3 } + ); + } +); + +test('on a touch screen, a handle on the edge between the page and DevTools resizes them', { skip }, async () => { + // A touch screen, as the shell sees it; a resize lays the shell out again. + await page.evaluate(() => { + const matchMedia = window.matchMedia.bind(window); + Reflect.set(window, '__matchMedia', window.matchMedia); + window.matchMedia = query => (query === '(pointer: coarse)' ? matchMedia('all') : matchMedia(query)); + window.dispatchEvent(new Event('resize')); + }); + /** The handle's center, once shown. */ + const handle = () => + page.evaluate(() => { + const el = document.querySelector('body > div > div[title="Drag to resize"]'); + if (!el || !el.getClientRects().length) return null; + const { left, top, width, height } = el.getBoundingClientRect(); + return { x: left + width / 2, y: top + height / 2 }; + }); + try { + const start = await waitFor(handle, 'the touch handle'); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x, start.y - 150, { steps: 5 }); + await page.mouse.up(); + // DevTools took the room (as much as its minimum sizes allow): the edge, and the handle on it, + // moved up. + await waitFor(async () => { + const now = await handle(); + return now && now.y < start.y - 100; + }, 'the handle to move up'); + } finally { + await page.evaluate(() => { + const original: unknown = Reflect.get(window, '__matchMedia'); + if (original instanceof Function) window.matchMedia = query => Reflect.apply(original, window, [query]); + window.dispatchEvent(new Event('resize')); + }); + } +}); + +test("device mode's page is only one next to the frontend's", { skip }, async () => { + // Same origin as the frontend, but not its page: refused. + await ( + await frontend() + ).evaluate(() => + parent.postMessage({ devtoolsAnywhere: { type: 'toolbox', url: '/tests/e2e/pages/device-mode.html' } }, '*') + ); + await new Promise(r => setTimeout(r, 300)); + assert.equal(await page.evaluate(() => document.querySelectorAll('body > iframe[title="Device mode"]').length), 0); +}); + +test('removed from the shell, the element leaves the page filling it, not placed', { skip }, async () => { + const style = await page.evaluate(() => { + document.querySelector('devtools-anywhere')?.remove(); + const frame = document.getElementById('devtools-anywhere-page'); + return frame && { position: frame.style.position, transform: frame.style.transform, height: frame.style.height }; + }); + assert.deepEqual(style, { position: '', transform: '', height: '' }); +}); + +test('no uncaught errors', { skip }, () => { + assert.deepEqual(errors, []); +}); diff --git a/tests/e2e/pages/device-mode.html b/tests/e2e/pages/device-mode.html new file mode 100644 index 0000000..095f70a --- /dev/null +++ b/tests/e2e/pages/device-mode.html @@ -0,0 +1,20 @@ + + + + + device mode + + + + +

hello

+ + diff --git a/tests/e2e/real-frontend.test.ts b/tests/e2e/real-frontend.test.ts index 50f6ddb..590163f 100644 --- a/tests/e2e/real-frontend.test.ts +++ b/tests/e2e/real-frontend.test.ts @@ -237,14 +237,16 @@ test('the Sources panel shows source and Watch, without breakpoints or pausing c // Watch stays: without pausing, its expressions are evaluated in the global scope. assert.equal(await (await query('[aria-label="Watch"]')).evaluate(el => el === null), false, 'Watch'); + /** The element, once laid out: there is a point to click on. */ + const laidOut = async (el: ElementHandle | null) => (el && (await el.boundingBox()) ? el : null); const addWatch = async () => // asElement() is typed as a handle to any Node; the page function returns an Element or null. (await query('[aria-label="Add watch expression"]')).asElement() as ElementHandle | null; - await (await waitFor(addWatch)).click(); + await (await waitFor(async () => laidOut(await addWatch()))).click(); const prompt = async () => // asElement() is typed as a handle to any Node; the page function returns an Element or null. (await query('.text-prompt[contenteditable]')).asElement() as ElementHandle | null; - await (await waitFor(prompt)).click(); + await (await waitFor(async () => laidOut(await prompt()))).click(); await frontend.page().keyboard.type('document.title'); await frontend.page().keyboard.press('Enter'); await waitFor(async () => (await text()).includes('document.title: "real frontend"')); @@ -253,7 +255,7 @@ test('the Sources panel shows source and Watch, without breakpoints or pausing c const lineNumber = async () => // asElement() is typed as a handle to any Node; the page function returns an Element or null. (await query('.cm-lineNumbers .cm-gutterElement:nth-child(2)')).asElement() as ElementHandle | null; - await (await waitFor(lineNumber)).click(); + await (await waitFor(async () => laidOut(await lineNumber()))).click(); await new Promise(r => setTimeout(r, 500)); assert.equal(await (await query('.cm-breakpoint')).evaluate(el => el === null), true); const breakpoints = await frontend.evaluate(async () => { @@ -386,6 +388,14 @@ test('the screencast is off by default and shows the page when turned on', { ski // asElement() is typed as a handle to any Node; the page function returns an Element or null. const button = toggle.asElement() as ElementHandle | null; assert.ok(button, 'the toolbar offers the screencast'); + // Device mode needs the shell, which can resize the page (device-mode.test.ts). + const deviceMode = await frontend.evaluate(() => { + const find = (root: Document | ShadowRoot): boolean => + !!root.querySelector('[aria-label="Toggle device toolbar"]') || + [...root.querySelectorAll('*')].some(el => !!el.shadowRoot && find(el.shadowRoot)); + return find(document); + }); + assert.equal(deviceMode, false, 'no device mode outside the shell'); await button.click(); await waitFor(async () => ((await frames()) ?? 0) > 0); await button.click(); diff --git a/tests/support/browser.ts b/tests/support/browser.ts index 037487d..4cce5b5 100644 --- a/tests/support/browser.ts +++ b/tests/support/browser.ts @@ -10,13 +10,23 @@ export interface Env { close(): Promise; } -/** `args` are extra Chrome flags, e.g. to turn on an experimental feature. */ -export async function launch(args: string[] = []): Promise { +/** + * `args` are extra Chrome flags, e.g. to turn on an experimental feature. `defaultViewport: null` + * leaves pages the window's size and the screen's scale factor, rather than Puppeteer's 800 × 600 + * at 1. + */ +export async function launch(args: string[] = [], options: { defaultViewport?: null } = {}): Promise { const server = await startServer(0); let browser: Browser; try { // A call that hangs fails after 30s rather than Puppeteer's default 180s. - browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args, protocolTimeout: 30_000 }); + browser = await puppeteer.launch({ + executablePath: CHROME, + headless: true, + args, + protocolTimeout: 30_000, + ...options, + }); } catch (e) { // Otherwise the listening server keeps the worker alive and hides the launch error. server.close();