diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5d7e2a1..bc87ad1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,8 @@ jobs: contents: read build: - needs: test + # 二进制内嵌浏览器扩展(internal/pkg/browserext),要等扩展构建完成。 + needs: [test, extension] runs-on: ubuntu-latest strategy: fail-fast: false @@ -56,6 +57,10 @@ jobs: with: go-version-file: go.mod cache: true + - uses: actions/download-artifact@v8 + with: + name: extension-dist + path: internal/pkg/browserext/dist - name: 构建并打包 env: GOOS: ${{ matrix.goos }} @@ -127,6 +132,12 @@ jobs: name: release-extension path: dist/*.zip if-no-files-found: error + # 同一份产物供 build 任务内嵌进二进制;不带 release- 前缀,不会被当成发布产物上传。 + - uses: actions/upload-artifact@v7 + with: + name: extension-dist + path: extension/dist/ + if-no-files-found: error release: name: Create Release @@ -207,6 +218,19 @@ jobs: -e "s|^ sha256 \".*\"$| sha256 \"${SHA256}\"|" \ Formula/sctl.rb grep -qF "$URL" Formula/sctl.rb && grep -qF "$SHA256" Formula/sctl.rb + # 源码包没有 Node 构建产物:formula 以 resource 下载同版本的扩展 zip,放进内嵌目录再编译。 + # 只改这个 resource 块里的行;tap 还没有这个块时照常更新源码包,只是装出的 sctl 不带扩展。 + if grep -q '^ resource "browser-extension" do$' Formula/sctl.rb; then + EXT_URL="https://github.com/scriptscat/sctl/releases/download/${TAG}/sctl-browser-extension-${VERSION}.zip" + EXT_SHA256=$(curl -fsSL "$EXT_URL" | sha256sum | cut -d' ' -f1) + sed -i -E '/^ resource "browser-extension" do$/,/^ end$/ { + s|^ url ".*"$| url "'"${EXT_URL}"'"| + s|^ sha256 ".*"$| sha256 "'"${EXT_SHA256}"'"| + }' Formula/sctl.rb + grep -qF "$EXT_URL" Formula/sctl.rb && grep -qF "$EXT_SHA256" Formula/sctl.rb + else + echo "::warning::Formula/sctl.rb has no browser-extension resource; brew installs will not bundle the extension" + fi # 重跑同一个 tag 时 formula 已是最新,不再产生空提交。 if git diff --quiet; then echo "Formula/sctl.rb 已是 ${VERSION},跳过。" diff --git a/.gitignore b/.gitignore index 0dc96b9..9947283 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ /sctl /bin/ dist/ +# 内嵌扩展的产物由 make extension-embed 拷入,只提交占位文件(见 internal/pkg/browserext) +!/internal/pkg/browserext/dist/ +/internal/pkg/browserext/dist/* +!/internal/pkg/browserext/dist/.gitkeep .dev-kit # 一次性端到端验证脚本与证据(见 docs/verification.md),永远不进版本库 /e2e/scratch/ diff --git a/Makefile b/Makefile index 9ebf938..cc8f4cf 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,9 @@ VERSION_PACKAGE := github.com/scriptscat/sctl/internal/cli SCRIPTCAT_DIR ?= ../scriptcat BROWSER_PROTOCOL_DIR := extension/src/protocol/generated PROTOCOL_GENERATED_DIRS := internal/pkg/protocol/generated $(BROWSER_PROTOCOL_DIR) +EMBED_DIR := internal/pkg/browserext/dist -.PHONY: help build test lint dev protocol-generate protocol-sync-scriptcat protocol-check clean +.PHONY: help build test lint dev extension-embed protocol-generate protocol-sync-scriptcat protocol-check clean help: ## 显示可用命令 @awk 'BEGIN {FS = ":.*## "; printf "用法: make <命令>\n\n命令:\n"} /^[a-zA-Z_-]+:.*## / {printf " %-10s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -25,6 +26,12 @@ test: ## 运行竞态检测测试 lint: ## 运行静态检查 $(GOLANGCI_LINT) run ./... +extension-embed: ## 构建浏览器扩展并拷进内嵌目录,之后的 build 带上扩展(需要 pnpm) + pnpm --dir extension install --frozen-lockfile + pnpm --dir extension build + find $(EMBED_DIR) -mindepth 1 ! -name .gitkeep -exec rm -rf {} + + cp -R extension/dist/. $(EMBED_DIR)/ + dev: ## 构建并启动本地 daemon(DEV_VERSION=0.1.0) mkdir -p $(BUILD_DIR) $(GO) build -ldflags "-X $(VERSION_PACKAGE).Version=$(DEV_VERSION)" -o $(BINARY) ./cmd/sctl diff --git a/README.md b/README.md index 76910e0..f169da8 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,10 @@ The install script verifies the release archive's sha256 and installs `sctl` int per extension. - **ScriptCat:** enable **External Access** in its options and enter the code. - - **sctl Browser:** download `sctl-browser-extension-.zip` from - [GitHub Releases](https://github.com/scriptscat/sctl/releases), unzip it, load the folder as an unpacked - extension, and enter a code in its popup. It needs **Chrome 125 or newer** (or a Chromium browser of that - version). + - **sctl Browser:** run `sctl browsers install`, load the directory it prints as an unpacked extension + (`chrome://extensions` → Developer mode → Load unpacked), and enter a code in its popup. sctl keeps that + directory up to date and reloads the extension after upgrades. It needs **Chrome 125 or newer** (or a + Chromium browser of that version). 3. **Check the connection:** @@ -120,7 +120,7 @@ browser's logins; see [`docs/cli.md`](./docs/cli.md#endpoint-for-playwright-and- |---|---| | `sctl serve` / `connect` / `status` / `mcp` | Run the daemon, pair an extension, show connection status, serve MCP over stdio. | | `sctl get` / `grep` / `install` / `edit` / `enable` / `disable` / `delete` | Manage ScriptCat userscripts. | -| `sctl browsers` | List paired sctl Browser instances, or forget one. | +| `sctl browsers` | List paired sctl Browser instances, forget one, or install the bundled extension. | | `sctl tabs` / `windows` / `groups` | Tabs, windows, and tab groups. | | `sctl bookmarks` / `reading-list` | Bookmarks and the reading list. | | `sctl history` / `recent` / `downloads` | History, recently closed tabs and windows, downloads. | diff --git a/docs/README_zh-CN.md b/docs/README_zh-CN.md index 5ccdd8d..11b0bf2 100644 --- a/docs/README_zh-CN.md +++ b/docs/README_zh-CN.md @@ -64,8 +64,9 @@ irm https://raw.githubusercontent.com/scriptscat/sctl/main/scripts/install.ps1 | 2. **配对扩展。** `sctl connect` 打印一次性配对码;一个码只能配对一个扩展,每个扩展各运行一次。 - **ScriptCat:** 在选项页启用 **External Access** 并输入配对码。 - - **sctl Browser:** 从 [GitHub Releases](https://github.com/scriptscat/sctl/releases) 下载 - `sctl-browser-extension-.zip` 并解压,把解压后的目录作为“已解压的扩展程序”加载,在其弹窗里输入配对码。 + - **sctl Browser:** 运行 `sctl browsers install`,把它打印的目录作为“已解压的扩展程序”加载 + (`chrome://extensions` → 开发者模式 → 加载已解压的扩展程序),在其弹窗里输入配对码。sctl 会保持这个目录为最新, + 升级后自动重新加载扩展。 要求 **Chrome 125 或更高版本**(或同版本的 Chromium 内核浏览器)。 3. **检查连接:** @@ -110,7 +111,7 @@ ln -s "$PWD/skills/sctl" ~/.claude/skills/sctl |---|---| | `sctl serve` / `connect` / `status` / `mcp` | 运行 daemon、配对扩展、查看连接状态、通过 stdio 提供 MCP。 | | `sctl get` / `grep` / `install` / `edit` / `enable` / `disable` / `delete` | 管理 ScriptCat 用户脚本。 | -| `sctl browsers` | 列出已配对的 sctl Browser 实例,或忘记其中一个。 | +| `sctl browsers` | 列出已配对的 sctl Browser 实例、忘记其中一个,或安装内嵌的扩展。 | | `sctl tabs` / `windows` / `groups` | 标签页、窗口和标签组。 | | `sctl bookmarks` / `reading-list` | 书签和阅读列表。 | | `sctl history` / `recent` / `downloads` | 历史记录、最近关闭的标签页和窗口、下载。 | diff --git a/docs/architecture.md b/docs/architecture.md index ce616b7..e759ddf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -144,7 +144,7 @@ internal/cli/ # subcommand definitions; spans both sides, hence to connect.go status.go version.go get.go grep.go # read verbs edit.go write.go # write verbs (edit / install / enable / disable / delete) - browsers.go # sctl browsers [list] / sctl browsers forget + browsers.go # sctl browsers [list] / sctl browsers forget / sctl browsers install tabs.go windows.go # sctl tabs list|open|close|activate, sctl windows list page*.go # sctl page snapshot|click|hover|fill|type|press|select|upload|scroll|goto|back|forward| # reload|wait|screenshot|eval|dialog|detach (reach /control/page through dispatchPage) @@ -166,6 +166,8 @@ internal/daemon/ # ── sctl serve side ── # page actions, per-tab debug record buffers, the bridge-backed CDP implementation cdpendpoint/ # raw CDP endpoint: per-browser endpoints, /cdp/ HTTP and WS, Host/Origin/secret checks, # expiry, hand-over to and from page, the per-client Hook and Browser + extupdate/ # keeps the sctl-managed extension directory on the bundled extension: on a handshake + # reporting an older bundle, rewrites the directory and calls extension.reload auth/ # mutual HMAC handshake, enrollment-code derivation (HKDF), key delivery (AES-GCM) store/ # persistence (repository role): ScriptCat's long-term key K, plus the # browsers.json registry of paired sctl Browser instances and their own @@ -183,6 +185,8 @@ internal/pkg/ # ── shared by both sides ── paths/ # data directory and derived paths logging/ # unified zap logging (stderr + /logs/*.log, never stdout) fsutil/ # atomic file writes + browserext/ # the sctl Browser extension embedded in the binary (dist/ is filled before go build) + # and writing it into the sctl-managed extension directory extension/ # ── sctl Browser, the second extension kind (MV3, pnpm/Vite/React) ── src/background/ # service worker: identity and settings storage, message routing, method dispatch diff --git a/docs/cli.md b/docs/cli.md index 98089f4..7731077 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -32,6 +32,7 @@ command line can use the skill in [`../skills/sctl/`](../skills/sctl/SKILL.md). | Command | Purpose | |---|---| | `sctl browsers [list]` / `sctl browsers forget ` | List paired sctl Browser instances, or forget one. | +| `sctl browsers install` | Write the sctl Browser extension bundled in the binary to a fixed directory to load in the browser; see [Installing sctl Browser](#installing-sctl-browser-sctl-browsers-install). | | `sctl tabs list\|open\|close\|activate` | List, open, close, or activate tabs on a paired sctl Browser instance. | | `sctl tabs move\|pin\|unpin\|mute\|unmute\|reload\|duplicate` | Move, pin, mute, reload, or duplicate tabs (several IDs are all-or-nothing). | | `sctl windows list` | List windows on a paired sctl Browser instance. | @@ -81,6 +82,25 @@ command line can use the skill in [`../skills/sctl/`](../skills/sctl/SKILL.md). | `sctl cdp send [--params ''] [--tab N] [--timeout D]` | Send one raw Chrome DevTools Protocol command to a tab's page and print Chrome's result. | | `sctl cdp endpoint` / `sctl cdp status` / `sctl cdp close` | Create or show a browser's CDP endpoint addresses for Playwright `connectOverCDP` and Puppeteer `connect`, show whether a client is connected and when the endpoint expires, or close it. | +## Installing sctl Browser: `sctl browsers install` + +`sctl browsers install` writes the extension bundled in the binary to `/browser-extension` and prints that +path, the extension version, and how to load it: open `chrome://extensions`, turn on Developer mode, choose +**Load unpacked**, and select the directory (the macOS dialog hides `~/Library`; press Cmd+Shift+G and paste the +path). Then pair it with a `sctl connect` code in its popup. It does not need the daemon, leaves the directory alone +when it already holds the same extension, and prints `{dir, version, changed}` with `-o json`. A binary built without +a bundled extension (a plain `go build`) exits 3; download the extension zip from the release instead. + +The directory holds a `sctl-bundle.json` marker with the extension's content digest, which the extension reports when +it connects. When a daemon whose bundled extension differs sees that report — after you upgrade sctl and restart the +daemon — it rewrites the directory with its own bundle and has the extension reload itself from disk; the pairing +survives. Once the reload has reached an instance the daemon does not ask that instance again until it restarts (a +failure before that, such as the instance going offline, is retried at its next connection), and it never touches an +extension loaded from a release zip, which has no marker. The extension reports the digest of the code it is running, +read once after it loads, so a directory rewritten under a running extension (for example by `sctl browsers install`) +is caught the next time that extension connects. The directory always ends up with the daemon's bundle, even when the `sctl` that +ran `install` is a different version. + ## Conventions ### Approval and immediate commands diff --git a/docs/development.md b/docs/development.md index fac8d22..40431db 100644 --- a/docs/development.md +++ b/docs/development.md @@ -146,6 +146,9 @@ It produces 7 artifacts — 6 sctl archives (darwin/linux/windows × amd64/arm64 `sctl-browser-extension-.zip` — plus `checksums.txt`, generates build provenance for the checksum file, and publishes the GitHub Release automatically after the full test gate passes. The extension zip holds one `sctl-browser-extension-/` directory for "Load unpacked"; its manifest version is the release version. +The `extension` job also hands its build output to the six binary builds, which copy it into +`internal/pkg/browserext/dist/` before `go build`, so every released binary carries the same extension for +`sctl browsers install`. Artifacts are built with `-trimpath` and use the commit time as the archive member timestamp, so rebuilding the same tag is byte-for-byte reproducible. Version, commit, and build time are injected into `internal/cli` @@ -155,7 +158,9 @@ Cross-compilation, packaging, checksums, and the GitHub Release upload all happe For a non-prerelease tag, the `homebrew` job then points `Formula/sctl.rb` in [scriptscat/homebrew-tap](https://github.com/scriptscat/homebrew-tap) at the tag's source tarball and pushes the -commit. It authenticates with the `HOMEBREW_TAP_DEPLOY_KEY` secret, the private half of a write-enabled deploy key +commit. The formula builds from source without Node, so it also downloads the tag's extension zip as its +`browser-extension` resource and puts it into `internal/pkg/browserext/dist/`; the job updates that resource's URL and +SHA-256 too, and only warns when the formula has no such resource. It authenticates with the `HOMEBREW_TAP_DEPLOY_KEY` secret, the private half of a write-enabled deploy key on the tap repository. ## Browser extension @@ -173,6 +178,10 @@ pnpm test # Vitest, with chrome.* and WebSocket replaced by fakes pnpm build # writes dist/, loadable through "Load unpacked" ``` +A plain `go build` leaves `internal/pkg/browserext/dist/` holding only its placeholder, so the binary has no +bundled extension: `sctl browsers install` exits 3 and the daemon skips extension updates. `make extension-embed` +builds the extension and copies it there (it needs pnpm), after which `make build` or `go build` bundles it. + `SCTL_EXTENSION_VERSION` sets the manifest version for a build (the release workflow passes the tag version; a prerelease suffix such as `-rc.1` goes into `version_name`, because Chrome versions are numeric only). Without it, the build uses the `version` in `extension/package.json`. The generated protocol bindings in diff --git a/docs/mcp.md b/docs/mcp.md index 165eca5..bde350e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -124,14 +124,17 @@ independently, but both use the same one-time-code flow against the same running 3. Enter the displayed one-time code: - For ScriptCat: open its options page, enable **External Access**, and enter the code in the enrollment dialog. - - For sctl Browser: download `sctl-browser-extension-.zip` from - [GitHub Releases](https://github.com/scriptscat/sctl/releases) (its version matches the sctl release), - unzip it, open your browser's extensions page, enable developer mode, choose "Load unpacked", and select - the unzipped folder. Open the extension's popup and enter the same code. - - sctl Browser requires Chrome 125 or newer and the `debugger` permission. When you update an unpacked copy, - reload it from the extensions page; Chrome will not load a build whose minimum version is above the running - browser's. While the extension has the debugger attached to a page, Chrome shows a "sctl Browser started + - For sctl Browser: run `sctl browsers install`, which writes the extension bundled in sctl to a fixed directory + and prints it. Open your browser's extensions page, enable developer mode, choose "Load unpacked", and select + that directory. Open the extension's popup and enter the same code. After you upgrade sctl and restart the + daemon, it updates that directory and reloads the extension for you + (see [`cli.md`](./cli.md#installing-sctl-browser-sctl-browsers-install)). Alternatively, download + `sctl-browser-extension-.zip` from [GitHub Releases](https://github.com/scriptscat/sctl/releases) + (its version matches the sctl release) and load the unzipped folder the same way; such a copy is not updated + automatically, so reload it from the extensions page after replacing its files. + + sctl Browser requires Chrome 125 or newer and the `debugger` permission; Chrome will not load a build whose + minimum version is above the running browser's. While the extension has the debugger attached to a page, Chrome shows a "sctl Browser started debugging this browser" infobar at the top of the window; the extension cannot hide it, and dismissing it detaches the debugger. Launch Chrome with `--silent-debugger-extension-api` to suppress the infobar. 4. Run `sctl connect` again for the second extension if you want to pair both — the code is valid only for a diff --git a/docs/protocol.md b/docs/protocol.md index 6aae073..9dcf289 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -163,7 +163,10 @@ A browser instance also declares its name and self-reported product details: ``` `name` is 1–32 lowercase letters, digits, and `-`; the other fields are optional printable text of at most 64 -bytes. A missing or invalid `peer` closes the connection. On its first pairing an instance proposes its default +bytes, except the optional `bundle`: 64 lowercase hex digits, the content digest recorded in the `sctl-bundle.json` +marker that sctl writes into the extension directory it manages, read once after the extension loads and kept in +`chrome.storage.session` so it describes the running code even after the directory on disk changes. An extension +loaded from anywhere else has no marker and omits `bundle`. A missing or invalid `peer` closes the connection. On its first pairing an instance proposes its default name, the browser brand followed by `-` and the first four hex digits of its instance ID; renaming means reconnecting with the new name. The daemon's registry is authoritative: names are unique across every paired instance, online or offline. When another paired instance holds the name, the daemon answers the capabilities @@ -445,7 +448,16 @@ answers it with `INVALID_REQUEST` exactly as for an unknown method, and `sctl mc so a control-token holder cannot send it directly. The `debugger.*` methods are internal because they relay raw Chrome DevTools Protocol (CDP) traffic with the user's signed-in browser state. `tabs.current` and `tabs.select` are internal because they exist only to serve the daemon's page automation, which reaches callers through -`/control/page` instead. +`/control/page` instead. `extension.reload` is internal because only the daemon's extension updater uses it. + +`extension.reload` takes and returns an empty object. The extension answers first and then calls +`chrome.runtime.reload()`, which for an unpacked extension reads every file from disk again; storage stays with the +extension ID, so the instance ID and session key survive. The daemon sends it right after the handshake of an +instance whose reported `bundle` differs from the extension bundled in the daemon, and only after writing that bundle +into its own extension directory (`/browser-extension`). Once it has reached an instance (answered, or the +connection closed because the extension was already reloading), the daemon does not send it to that instance again +until the daemon restarts; a failure before that is retried at the instance's next handshake. A daemon built +without a bundled extension never sends it. `debugger.send` input is `{tabId, sessionId?, method, params?}`: `method` and `params` are the CDP command, sent to the tab's top-level debugger session, or to the child session `sessionId` — the `sessionId` of a CDP diff --git a/docs/specs/2026-10-23-embedded-extension.md b/docs/specs/2026-10-23-embedded-extension.md new file mode 100644 index 0000000..948cb27 --- /dev/null +++ b/docs/specs/2026-10-23-embedded-extension.md @@ -0,0 +1,122 @@ +# 把 sctl Browser 扩展内嵌进 sctl 二进制 + + + +> Status: Draft +> Owner: sctl maintainers +> Last updated: 2026-10-23 + +**目标:** 用户不必再去 GitHub Releases 下载、解压扩展:`sctl browsers install` 把二进制里内嵌的扩展释放到一个固定目录, +用户在浏览器里加载这个目录一次即可;之后 sctl 升级时,daemon 发现扩展落后,就更新这个目录并让扩展重新加载, +扩展版本始终跟着 sctl 走。 + +**不可退化的约束:** + +- 不需要 Node 也能 `go build ./...` 和 `go test ./...`;没有内嵌扩展的构建照常可用,只是 `sctl browsers install` 报错、daemon 不做自动更新。 +- 从 Release zip 手动加载的扩展行为不变:daemon 不会改它、也不会让它重新加载。 +- 给 ScriptCat 的生成 TS 文件逐字节不变。 +- 扩展的会话密钥和实例 ID 在自动更新后保留,不需要重新配对。 + +## 问题 + +1. **装扩展要五步手动操作。** 已验证。README 快速开始第 2 步:从 Releases 下载 `sctl-browser-extension-.zip`、 + 解压、打开开发者模式、加载已解压的扩展程序、在弹窗输入配对码。 +2. **扩展不会跟着 sctl 升级。** 已验证。扩展和 sctl 同版本发布(`.github/workflows/release.yaml` 的 `extension` 任务), + 但升级 sctl 不会更新已加载的扩展;daemon 只把 `extensionVersion` 记下来展示(`sctl browsers` 的 EXTENSION 列),不做任何处理。 +3. **静默安装不可行。** 普通用户的 Chrome 只能从应用商店一键安装;外部扩展注册(External Extensions JSON)在 Windows 和 macOS + 上只认指向商店的 update URL;企业策略要管理员权限;品牌版 Chrome 不再接受 `--load-extension`。所以“加载已解压的扩展程序” + 这一步只能由用户做,本 spec 让它只做一次。 + +## 角色与用户故事 + +1. 作为新用户,我希望装好 sctl 之后用一条命令拿到扩展目录,在浏览器里加载一次就能配对,这样不用再去下载页找对应版本的 zip。 +2. 作为升级了 sctl 的用户,我希望扩展自动变成新版本,配对不丢,这样不用每次升级都重新下载、重新加载扩展。 +3. 作为从源码构建的开发者,我希望不装 Node 也能编译和测试 Go 代码。 + +## 设计决策 + +| # | 决策 | 依据与被否方案 | +|---|---|---| +| 1 | 用 `go:embed` 把扩展的构建产物内嵌进二进制,`sctl browsers install` 把它释放到 `<数据目录>/browser-extension/`。 | 用户决定(先做嵌入)。目录固定,Chrome 按路径识别已解压的扩展,路径不变才能原地更新。被否:上架应用商店(另行推进,审核不受控);让用户自选目录(daemon 就得根据扩展上报的路径去改用户指定的目录,扩大了写入面)。 | +| 2 | 内嵌目录在源码里只放一个占位文件,扩展产物由 `make extension-embed` 或发布流程在 `go build` 之前拷进去。 | 约束 1:不需要 Node 也能编译。被否:把构建产物提交进仓库——每次改扩展都要提交一大堆带哈希的文件。 | +| 3 | sctl 写出的扩展目录里放一个标记文件 `sctl-bundle.json`,记录这份扩展内容的摘要(全部文件路径与内容的 SHA-256)。扩展加载后第一次读取它并缓存在 `chrome.storage.session`(扩展重新加载或浏览器重启时清空),在能力声明里上报 `bundle`:上报的是正在运行的代码,而不是磁盘现状——目录可能在扩展运行期间被改写,service worker 休眠重启后读到的新标记会掩盖旧代码。没有标记文件的扩展(从 Release zip 加载的)不上报。 | 摘要比版本号可靠:同一版本号的预发布(`0.3.0-rc.1` 与 `-rc.2` 的 manifest `version` 都是 `0.3.0`)和本地开发构建内容不同,版本号区分不出。标记文件只存在于 sctl 管理的目录里,天然区分“sctl 管的”和“用户手动加载的”(约束 2)。 | +| 4 | daemon 在浏览器实例完成握手后比较:上报了 `bundle`、且与 daemon 内嵌的摘要不同,就把内嵌扩展写进 daemon 自己的扩展目录,再调用新的内部方法 `extension.reload` 让扩展重新加载。 | 已解压的扩展调用 `chrome.runtime.reload()` 会从磁盘重新读取全部文件;存储(实例 ID、会话密钥)跟着扩展 ID 走,路径不变 ID 就不变,配对保留(约束 4)。被否:只在 `sctl browsers` 里提示“扩展过期”——用户还得手动点重新加载。 | +| 5 | `extension.reload` 送达某个实例后(收到回复,或连接因扩展已在重新加载而关闭),这个 daemon 进程不再对它发;送达之前的失败(写目录出错、实例已离线)在它下次握手时重试。 | 扩展可能是从另一个数据目录的扩展目录加载的:daemon 更新的是自己的目录,重新加载后扩展上报的摘要不变,没有这条就会无限重新加载。被否:让扩展上报目录路径、daemon 只改那个目录——写入目标由对端决定,安全面更大,且路径比较受符号链接影响。 | +| 6 | 目录整体替换:先写到同级的临时目录,再把旧目录改名移开、新目录改名就位,最后删除旧目录。 | 扩展文件名带内容哈希,新旧文件混在一起会留下垃圾;整体替换让目录在任一时刻要么是完整的旧版、要么是完整的新版。 | +| 7 | 发布流程先构建扩展,再把产物交给每个平台的二进制构建任务;Homebrew formula 用 `resource` 下载同版本的 Release 扩展 zip 放进内嵌目录。 | formula 从源码构建,没有 Node;Release 里的扩展 zip 就是同一份产物。 | + +## 命令 + +- **`sctl browsers install`** + - 把内嵌扩展写进 `<数据目录>/browser-extension/`;目录里已是同一份内容时不改动。不经过 daemon。 + - 输出目录路径、扩展版本和加载步骤:打开 `chrome://extensions`,打开开发者模式,点“加载已解压的扩展程序”选这个目录 + (macOS 的选择框默认隐藏 `~/Library`,按 Cmd+Shift+G 粘贴路径),然后用 `sctl connect` 的配对码在扩展弹窗里配对。 + - `-o json` 输出 `{dir, version, changed}`。 + - 二进制没有内嵌扩展时退出码 3,说明这个构建不带扩展,可以从 Release 下载 zip。 +- 已加载的扩展目录被 `sctl browsers install` 改写后,正在运行的扩展在下次连上 daemon 时由自动更新让它重新加载。 + +## 自动更新 + +- **触发:** 浏览器实例完成能力声明之后(daemon 已回复),daemon 检查它上报的 `bundle`。 +- **条件:** daemon 内嵌了扩展;实例上报了 `bundle`;上报值与内嵌摘要不同;这个 daemon 进程还没把 reload 送达过这个实例。 +- **动作:** 扩展目录的标记或实际内容与内嵌扩展不同时(文件被删改、标记损坏都算),整体替换目录(决策 6);然后调用 `extension.reload`。扩展先回复,再重新加载自己; + 重新连上后上报新的摘要,不再触发。 +- **失败:** 写目录或调用失败只记 warn 日志,不影响连接和其他命令,下次握手时重试(决策 5)。 +- **遗留:** 上次中断留下的临时目录在下一次写入前清扫;旧目录删不掉(例如 Windows 上文件仍被占用)不算失败,同样留给下次清扫。 +- **daemon 与 CLI 版本不同:** 目录总是被改成 daemon 内嵌的那一份。升级 sctl 后重启 daemon(`sctl service restart` 或重新运行 `sctl serve`)即可。 + +## 协议 + +- `$session.capabilities` 的 `peer` 新增可选字段 `bundle`:64 位小写十六进制;格式不对时与其他非法 `peer` 一样关闭连接。 + 旧 daemon 忽略未知字段,新扩展连旧 daemon 不受影响。 +- 新增内部方法 `extension.reload`(只对浏览器扩展生成;L0;参数与结果都是空对象):扩展先回复,再调用 `chrome.runtime.reload()`。 + 只有 daemon 的自动更新会调用它,不对 `/control/call`、CLI 或 MCP 开放。 + +## 构建与发布 + +- **占位:** `internal/pkg/browserext/dist/` 只提交一个 `.gitkeep`;其余内容被忽略。 +- **本地:** `make extension-embed` 构建扩展并拷进内嵌目录,之后 `make build` / `go build` 得到带扩展的二进制。 +- **发布:** `extension` 任务额外上传构建产物目录;六个平台的构建任务依赖它,下载后放进内嵌目录再编译。扩展 zip 保持不变。 +- **Homebrew:** tap 的 formula 增加 `resource "browser-extension"`(同版本的 Release 扩展 zip),安装时放进内嵌目录;发布流程的 + `homebrew` 任务只改这个 resource 块里的 URL 和 SHA-256。tap 仓库的 formula 改动另提 PR;它合并之前发布流程照常更新源码包, + 只给出警告(装出的 sctl 不带扩展)。 + +## 安全与隐私 + +- 自动更新只写 daemon 自己数据目录下的固定目录,写入内容来自二进制本身,不来自对端;对端只能通过上报摘要触发“写入内嵌内容 + 重新加载”, + 而且每个实例只触发一次(决策 5)。持有会话密钥的扩展本来就在信任边界内。 +- `extension.reload` 只让扩展重新加载自己,不改变权限或配对。 +- 扩展目录不含任何凭据,不进入[威胁模型](../threat-model.md)的凭据清单;但它是浏览器以扩展权限执行的代码,威胁模型的攻击面表加一行。 + 数据目录还不存在时按惯例以 0700 创建。 + +## 不在本期范围 + +- 上架 Chrome 应用商店。 +- 用户自选扩展目录(决策 1)。 +- Firefox、Safari。 + +## 文档 + +- **README.md 与 README_zh-CN.md:** 快速开始第 2 步改为 `sctl browsers install`,Release zip 作为备选。 +- **[cli.md](../cli.md):** `sctl browsers install` 的行为与自动更新。 +- **[mcp.md](../mcp.md):** 安装扩展的步骤。 +- **[protocol.md](../protocol.md):** `peer.bundle`、`extension.reload`。 +- **[architecture.md](../architecture.md):** 新包与自动更新在 daemon 中的位置。 +- **[development.md](../development.md):** `make extension-embed`、发布流程的变化。 +- **[threat-model.md](../threat-model.md):** 攻击面表加上“经扩展目录植入代码”一行。 + +## 测试决策 + +| 测试层 | 验证什么 | 可参照的现有测试 | +|---|---|---| +| Go 内嵌包单元测试(`fstest.MapFS`) | 摘要只取决于文件路径和内容;没有 `manifest.json` 视为未内嵌;安装写出全部文件和标记;同摘要不改动;旧目录被整体替换(旧文件消失);版本取 `version_name` 优先 | — | +| Go 自动更新单元测试(假 bridge) | 摘要不同 → 写目录并调用 `extension.reload`;相同、未上报、未内嵌 → 什么都不做;同一实例同一摘要只尝试一次;写目录失败不调用 reload | `internal/daemon/page/manager_test.go` | +| bridge 测试 | 握手完成后通知监听者并带上 `bundle`;非法 `bundle` 关闭连接 | `internal/daemon/bridge/server_test.go` | +| CLI 测试 | `browsers install` 的输出、`-o json`、未内嵌时退出码 3 | `internal/cli/browsers_test.go` | +| 扩展单元测试(Vitest) | 有标记文件时能力声明带 `bundle`、没有时不带;`extension.reload` 先回复再重新加载 | `extension/src/offscreen/connection.test.ts`、`extension/src/handlers/debugger.test.ts` | +| 协议生成检查 | 只改 Go 与浏览器扩展的生成代码,给 ScriptCat 的逐字节不变 | `make protocol-check` | +| 一次性真机验证,放在 `e2e/scratch/`,不提交 | Chrome for Testing 加载 `sctl browsers install` 写出的目录并配对;换上内嵌另一份扩展的 daemon 后,扩展自动重新加载、上报新版本、配对保留、只重新加载一次 | [verification.md](../verification.md) | + +## 未决问题 + +无。 diff --git a/docs/threat-model.md b/docs/threat-model.md index 4ace450..ff99ebb 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -125,6 +125,7 @@ holds or observes the address controls the browser. | Write operations are abused (installing a malicious script / bulk deletion) | Two-phase confirmation plus a TOCTOU re-check at the moment of approval (staged `contentHash`, target `existingCodeHash`); calls are purely blocking, so a requester disconnect voids them. The install page's own enable toggle decides the enabled state (installs are usable immediately, like a normal install). "Always-allow" is an explicit security-downgrade switch (amber warning in the UI) | Under "always-allow" a write is no longer confirmed by a human — the user takes that risk | | Source code leaks | Source disclosure is gated by its own **source-read policy** (approval by default), applied to the CLI and MCP alike — the CLI is **not** exempt; reading is a privacy matter and is not covered by the write policy. Script-controlled text is always returned as structured data (`contentTrust: untrusted-user-script-source`) and must never be concatenated into a tool description | Under a "always-allow" source-read policy, reads are no longer confirmed — the user takes that risk | | The port's existence is found by scanning | Accepted: the extension is the client and cannot read a discovery file, so the default port 8643 has to be fixed; authentication is the backstop | The open port is visible | +| Code is planted in the sctl Browser extension through the directory sctl manages for it | The directory (`/browser-extension`) sits in the user-only data directory and sctl writes it only from the extension embedded in its own binary; a paired extension can make the daemon rewrite it only by reporting an outdated content digest, which restores the embedded copy and reloads that instance once per daemon start ([protocol.md](./protocol.md#32-internal-methods)) | A same-user process can rewrite the directory; that is the same-user non-goal below | | A malicious same-user process reads the daemon key file / control token (0600) | **Explicit non-goal** — a process holding the user's full privileges is already past the capability boundary of any local scheme | See the explicit non-goals in §3 | ## 3. Explicit non-goals diff --git a/extension/src/background/bundle.test.ts b/extension/src/background/bundle.test.ts new file mode 100644 index 0000000..7213205 --- /dev/null +++ b/extension/src/background/bundle.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import { readBundleDigest, type SessionStore } from "./bundle"; + +const DIGEST = "0123456789abcdef".repeat(4); +const NEWER = "fedcba9876543210".repeat(4); + +function response(status: number, body: string): Response { + return new Response(body, { status }); +} + +function session(initial: Record = {}): SessionStore & { items: Record } { + const items = { ...initial }; + return { + items, + get: (keys: string[]) => + Promise.resolve(Object.fromEntries(keys.filter((k) => k in items).map((k) => [k, items[k]]))), + set: (values: Record) => { + Object.assign(items, values); + return Promise.resolve(); + }, + }; +} + +describe("readBundleDigest", () => { + it("returns the digest recorded in the marker sctl writes into its extension directory, and remembers it", async () => { + const store = session(); + const fetchFn = vi.fn(() => Promise.resolve(response(200, JSON.stringify({ digest: DIGEST })))); + await expect(readBundleDigest(fetchFn, "chrome-extension://id/sctl-bundle.json", store)).resolves.toBe(DIGEST); + expect(fetchFn).toHaveBeenCalledWith("chrome-extension://id/sctl-bundle.json"); + expect(store.items).toEqual({ bundle: DIGEST }); + }); + + it("keeps reporting the digest of the running code after the directory on disk was rewritten", async () => { + // service worker 休眠后重启时,磁盘上的标记可能已被换成新版,而运行的仍是旧代码。 + const store = session({ bundle: DIGEST }); + const fetchFn = vi.fn(() => Promise.resolve(response(200, JSON.stringify({ digest: NEWER })))); + await expect(readBundleDigest(fetchFn, "u", store)).resolves.toBe(DIGEST); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("returns null when the extension was loaded without the marker (from the release zip), and remembers that", async () => { + const store = session(); + const missing = vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))); + await expect(readBundleDigest(missing, "u", store)).resolves.toBeNull(); + expect(store.items).toEqual({ bundle: "" }); + await expect(readBundleDigest(missing, "u", store)).resolves.toBeNull(); + expect(missing).toHaveBeenCalledOnce(); + + const notFound = vi.fn(() => Promise.resolve(response(404, ""))); + await expect(readBundleDigest(notFound, "u", session())).resolves.toBeNull(); + }); + + it("returns null when the marker does not hold a lowercase sha-256 digest", async () => { + for (const body of ["not json", JSON.stringify({ digest: DIGEST.toUpperCase() }), JSON.stringify({})]) { + const fetchFn = vi.fn(() => Promise.resolve(response(200, body))); + await expect(readBundleDigest(fetchFn, "u", session())).resolves.toBeNull(); + } + }); +}); diff --git a/extension/src/background/bundle.ts b/extension/src/background/bundle.ts new file mode 100644 index 0000000..6ae716c --- /dev/null +++ b/extension/src/background/bundle.ts @@ -0,0 +1,43 @@ +// sctl 写进它管理的扩展目录的标记文件(internal/pkg/browserext):从 Release zip 手动加载的扩展没有它。 +export const BUNDLE_MARKER = "sctl-bundle.json"; + +const DIGEST = /^[0-9a-f]{64}$/; +const SESSION_KEY = "bundle"; + +export interface SessionStore { + get(keys: string[]): Promise>; + set(items: Record): Promise; +} + +// 读出标记文件记录的扩展内容摘要,在能力声明里上报,daemon 据此判断这份扩展是否落后于它内嵌的那份。 +// 没有标记文件或内容不对时返回 null:这样的扩展不由 sctl 管理,daemon 不会去更新或重新加载它。 +// +// 摘要必须对应正在运行的代码,而不是磁盘上的现状:目录可能在扩展运行期间被改写(sctl browsers install,或 daemon +// 写了目录但 reload 没送达),service worker 休眠后重启时读到的是新标记,运行的却仍是旧代码。所以只在扩展加载后 +// 第一次读取磁盘,结果记在 storage.session 里——它在扩展重新加载或浏览器重启时清空,正好与代码的生命周期一致。 +export async function readBundleDigest( + fetchFn: (url: string) => Promise, + url: string, + session: SessionStore, +): Promise { + const cached = (await session.get([SESSION_KEY]))[SESSION_KEY]; + if (typeof cached === "string") { + return cached === "" ? null : cached; + } + const digest = await readMarker(fetchFn, url); + await session.set({ [SESSION_KEY]: digest ?? "" }); + return digest; +} + +async function readMarker(fetchFn: (url: string) => Promise, url: string): Promise { + try { + const response = await fetchFn(url); + if (!response.ok) { + return null; + } + const body = (await response.json()) as { digest?: unknown }; + return typeof body.digest === "string" && DIGEST.test(body.digest) ? body.digest : null; + } catch { + return null; + } +} diff --git a/extension/src/background/controller.test.ts b/extension/src/background/controller.test.ts index 6a74642..7185c3f 100644 --- a/extension/src/background/controller.test.ts +++ b/extension/src/background/controller.test.ts @@ -101,6 +101,7 @@ function setup(storage = new MemoryStorage()) { version: "129.0.2792.65", }), extensionVersion: "0.1.0", + bundle: Promise.resolve("cd".repeat(32)), }); return { background, @@ -143,7 +144,7 @@ describe("instance identity and configuration", () => { expect(storage.items.get("instanceId")).toBe((a as { instanceId: string }).instanceId); }); - it("hands the offscreen document the stored pairing, the default address and the declared methods", async () => { + it("hands the offscreen document the stored pairing, the default address, the declared methods and the bundle digest", async () => { const { background, storage } = setup(); await storage.set({ key: "ab".repeat(32) }); @@ -154,6 +155,7 @@ describe("instance identity and configuration", () => { product: "Edge", productVersion: "129.0.2792.65", extensionVersion: "0.1.0", + bundle: "cd".repeat(32), }); }); diff --git a/extension/src/background/controller.ts b/extension/src/background/controller.ts index b006eae..fe537cc 100644 --- a/extension/src/background/controller.ts +++ b/extension/src/background/controller.ts @@ -32,6 +32,7 @@ export interface BackgroundDeps { // 浏览器的完整版本号只能异步取得(navigator.userAgentData.getHighEntropyValues)。 browser: Promise; extensionVersion: string; + bundle: Promise; } export interface BrowserInfo { @@ -153,6 +154,7 @@ export class Background { product: browser.product.product, productVersion: browser.version, extensionVersion: this.deps.extensionVersion, + bundle: await this.deps.bundle, }; } diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index ee56141..ce463a3 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -6,6 +6,7 @@ import type { BackgroundMessage, OffscreenCommand } from "@/shared/messages"; import { broadcast, listen, request } from "@/shared/messaging"; import { Approvals } from "./approvals"; import { readBrowserInfo } from "./browser-info"; +import { BUNDLE_MARKER, readBundleDigest } from "./bundle"; import { Background } from "./controller"; import { createOffscreenKeeper } from "./offscreen-document"; import { HandlerRegistry } from "./registry"; @@ -62,6 +63,7 @@ const background = new Background({ onConnectionClosed: () => relay.detachAll(), browser: readBrowserInfo(), extensionVersion: chrome.runtime.getManifest().version, + bundle: readBundleDigest(fetch, chrome.runtime.getURL(BUNDLE_MARKER), chrome.storage.session), }); // 监听器必须在顶层同步注册,service worker 被消息唤醒时才能收到这条消息。 diff --git a/extension/src/handlers/index.test.ts b/extension/src/handlers/index.test.ts index 7d705fe..c9df7fb 100644 --- a/extension/src/handlers/index.test.ts +++ b/extension/src/handlers/index.test.ts @@ -99,7 +99,7 @@ describe("browser method handlers", () => { vi.unstubAllGlobals(); }); - it("declares capabilities for exactly the tab, window, page-target helper, debugger, reading list, bookmark, tab management, history, browsing data, recently closed, downloads, cookies and extensions methods", () => { + it("declares capabilities for exactly the tab, window, page-target helper, debugger, reading list, bookmark, tab management, history, browsing data, recently closed, downloads, cookies and extensions methods, and extension.reload", () => { expect(new Set(registry.methods())).toEqual( new Set([ "tabs.list", @@ -169,6 +169,7 @@ describe("browser method handlers", () => { "debugger.open", "debugger.close", "debugger.own", + "extension.reload", ]), ); }); diff --git a/extension/src/handlers/index.ts b/extension/src/handlers/index.ts index e832230..facbd4e 100644 --- a/extension/src/handlers/index.ts +++ b/extension/src/handlers/index.ts @@ -8,6 +8,7 @@ import { registerExtensionsHandlers } from "./extensions"; import { registerHistoryHandlers } from "./history"; import { registerReadingListHandlers } from "./readingList"; import { registerRecentlyClosedHandlers } from "./recentlyClosed"; +import { registerRuntimeHandlers } from "./runtime"; import { requireTab, requireWindow } from "./targets"; import { registerTabGroupHandlers } from "./tabGroups"; import { registerTabsManageHandlers } from "./tabsManage"; @@ -126,4 +127,5 @@ export function registerHandlers(registry: HandlerRegistry, relay: DebuggerRelay registry.register("debugger.open", relay.open); registry.register("debugger.close", relay.close); registry.register("debugger.own", relay.own); + registerRuntimeHandlers(registry); } diff --git a/extension/src/handlers/runtime.test.ts b/extension/src/handlers/runtime.test.ts new file mode 100644 index 0000000..b717e53 --- /dev/null +++ b/extension/src/handlers/runtime.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import { HandlerRegistry } from "@/background/registry"; +import { validateExtensionReloadResult } from "@/protocol/generated/validators.generated"; +import { registerRuntimeHandlers } from "./runtime"; + +describe("extension.reload", () => { + it("answers first and reloads the extension only after the answer has had time to leave", async () => { + vi.useFakeTimers(); + try { + const reload = vi.fn(); + const registry = new HandlerRegistry(); + registerRuntimeHandlers(registry, reload); + + const result = await registry.dispatch("extension.reload", {}); + expect(result).toEqual({ ok: true, result: {} }); + expect(validateExtensionReloadResult({})).toBe(true); + expect(reload).not.toHaveBeenCalled(); + + await vi.runAllTimersAsync(); + expect(reload).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/extension/src/handlers/runtime.ts b/extension/src/handlers/runtime.ts new file mode 100644 index 0000000..84c55f5 --- /dev/null +++ b/extension/src/handlers/runtime.ts @@ -0,0 +1,16 @@ +import type { HandlerRegistry } from "@/background/registry"; + +// 回复要经 offscreen 文档的 WebSocket 发出;立即重新加载会连同 offscreen 文档一起销毁,回复就丢了。 +const RELOAD_DELAY_MS = 500; + +// extension.reload:daemon 更新了 sctl 管理的扩展目录之后调用。已解压的扩展重新加载时从磁盘读取全部文件, +// 存储跟着扩展 ID 走,实例 ID 和会话密钥都保留。 +export function registerRuntimeHandlers( + registry: HandlerRegistry, + reload: () => void = () => chrome.runtime.reload(), +): void { + registry.register("extension.reload", () => { + setTimeout(reload, RELOAD_DELAY_MS); + return Promise.resolve({}); + }); +} diff --git a/extension/src/offscreen/connection.fixture.ts b/extension/src/offscreen/connection.fixture.ts index 064af0e..a667741 100644 --- a/extension/src/offscreen/connection.fixture.ts +++ b/extension/src/offscreen/connection.fixture.ts @@ -21,6 +21,7 @@ export function config(overrides: Partial = {}): ConnectionCon product: "Chrome", productVersion: "129.0.6668.58", extensionVersion: "0.1.0", + bundle: null, ...overrides, }; } diff --git a/extension/src/offscreen/connection.test.ts b/extension/src/offscreen/connection.test.ts index adccc4e..9fbc320 100644 --- a/extension/src/offscreen/connection.test.ts +++ b/extension/src/offscreen/connection.test.ts @@ -51,6 +51,22 @@ describe("session handshake", () => { expect(h.states.at(-1)).toEqual(h.state()); }); + it("declares the bundle digest when the extension was loaded from an sctl-managed directory", async () => { + const bundle = "ab".repeat(32); + const h = harness({ bundle }); + const socket = h.socket(); + socket.open(); + await authenticate(socket, { auth: { mode: "session", key: SESSION_KEY } }); + const capabilities = await hello(socket); + expect(capabilities.params?.peer).toEqual({ + name: "chrome-3f2a", + product: "Chrome", + productVersion: "129.0.6668.58", + extensionVersion: "0.1.0", + bundle, + }); + }); + it("stops retrying and reports rejected when the daemon cannot prove it holds the session key", async () => { const h = harness(); const socket = h.socket(); diff --git a/extension/src/offscreen/connection.ts b/extension/src/offscreen/connection.ts index 9228332..17bcf59 100644 --- a/extension/src/offscreen/connection.ts +++ b/extension/src/offscreen/connection.ts @@ -550,6 +550,7 @@ export class Connection { product: config.product, productVersion: config.productVersion, extensionVersion: config.extensionVersion, + ...(config.bundle === null ? {} : { bundle: config.bundle }), }, }, }); diff --git a/extension/src/protocol/generated/protocol.generated.ts b/extension/src/protocol/generated/protocol.generated.ts index 5f25a3e..f69ac4e 100644 --- a/extension/src/protocol/generated/protocol.generated.ts +++ b/extension/src/protocol/generated/protocol.generated.ts @@ -330,6 +330,8 @@ export interface DownloadsStartParams { export interface DownloadsStartResult { id: number; } +export type ExtensionReloadParams = Record; +export type ExtensionReloadResult = Record; export interface ExtensionsDisableParams { confirm?: true; id: string; @@ -589,6 +591,7 @@ export interface RpcMethodMap { "downloads.resume": { params: DownloadsResumeParams; result: DownloadsResumeResult }; "downloads.show": { params: DownloadsShowParams; result: DownloadsShowResult }; "downloads.start": { params: DownloadsStartParams; result: DownloadsStartResult }; + "extension.reload": { params: ExtensionReloadParams; result: ExtensionReloadResult }; "extensions.disable": { params: ExtensionsDisableParams; result: ExtensionsSetEnabledResult }; "extensions.enable": { params: ExtensionsEnableParams; result: ExtensionsSetEnabledResult }; "extensions.list": { params: ExtensionsListParams; result: ExtensionsListResult }; @@ -871,6 +874,14 @@ export const RPC_METHODS = { blocking: "none", level: "L0", }, + "extension.reload": { + params: "ExtensionReloadParams", + result: "ExtensionReloadResult", + scope: "extension:reload", + effect: "write", + blocking: "none", + level: "L0", + }, "extensions.disable": { params: "ExtensionsDisableParams", result: "ExtensionsSetEnabledResult", diff --git a/extension/src/protocol/generated/validators.generated.ts b/extension/src/protocol/generated/validators.generated.ts index d6f070a..aa5296b 100644 --- a/extension/src/protocol/generated/validators.generated.ts +++ b/extension/src/protocol/generated/validators.generated.ts @@ -266,6 +266,14 @@ export function validateDownloadsStartResult(value: unknown): value is Protocol. return (isRecord(value) && hasOnlyKeys(value, ["id"]) && typeof value["id"] === "number" && Number.isInteger(value["id"]) && value["id"] >= 0); } +export function validateExtensionReloadParams(value: unknown): value is Protocol.ExtensionReloadParams { + return (isRecord(value) && hasOnlyKeys(value, [])); +} + +export function validateExtensionReloadResult(value: unknown): value is Protocol.ExtensionReloadResult { + return (isRecord(value) && hasOnlyKeys(value, [])); +} + export function validateExtensionsDisableParams(value: unknown): value is Protocol.ExtensionsDisableParams { return (isRecord(value) && hasOnlyKeys(value, ["confirm", "id"]) && (value["confirm"] === undefined || (value["confirm"] === true)) && typeof value["id"] === "string"); } @@ -549,6 +557,7 @@ export const RPC_PARAM_VALIDATORS = { "downloads.resume": validateDownloadsResumeParams, "downloads.show": validateDownloadsShowParams, "downloads.start": validateDownloadsStartParams, + "extension.reload": validateExtensionReloadParams, "extensions.disable": validateExtensionsDisableParams, "extensions.enable": validateExtensionsEnableParams, "extensions.list": validateExtensionsListParams, @@ -619,6 +628,7 @@ export const RPC_RESULT_VALIDATORS = { "downloads.resume": validateDownloadsResumeResult, "downloads.show": validateDownloadsShowResult, "downloads.start": validateDownloadsStartResult, + "extension.reload": validateExtensionReloadResult, "extensions.disable": validateExtensionsSetEnabledResult, "extensions.enable": validateExtensionsSetEnabledResult, "extensions.list": validateExtensionsListResult, diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 4ec7fe5..3822ace 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -42,6 +42,8 @@ export interface ConnectionConfig { product: string; productVersion: string; extensionVersion: string; + // sctl 管理的扩展目录里标记文件记录的摘要;手动加载的扩展为 null(见 background/bundle.ts)。 + bundle: string | null; } // 同一条 chrome.runtime 消息会送达除发送者外的所有扩展页面,target 决定由谁处理。 diff --git a/internal/cli/browsers.go b/internal/cli/browsers.go index 92785cf..602be61 100644 --- a/internal/cli/browsers.go +++ b/internal/cli/browsers.go @@ -3,25 +3,28 @@ package cli import ( "fmt" "os" + "path/filepath" "text/tabwriter" "time" "github.com/spf13/cobra" "github.com/scriptscat/sctl/internal/client/control" + "github.com/scriptscat/sctl/internal/pkg/browserext" + "github.com/scriptscat/sctl/internal/pkg/paths" ) -// newBrowsersCmd 构造 `sctl browsers`:裸调用等价于 `browsers list`,另挂 forget 子命令。 +// newBrowsersCmd 构造 `sctl browsers`:裸调用等价于 `browsers list`,另挂 forget 与 install 子命令。 func newBrowsersCmd() *cobra.Command { cmd := &cobra.Command{ Use: "browsers [list]", - Short: "List paired sctl Browser instances (default), or forget one with the forget subcommand", + Short: "List paired sctl Browser instances (default), forget one, or install the extension", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runBrowsersList(cmd) }, } - cmd.AddCommand(newBrowsersListCmd(), newBrowsersForgetCmd()) + cmd.AddCommand(newBrowsersListCmd(), newBrowsersForgetCmd(), newBrowsersInstallCmd()) return cmd } @@ -78,6 +81,56 @@ func newBrowsersForgetCmd() *cobra.Command { } } +// embeddedExtension 是测试替换点:真实实现读取二进制内嵌的扩展。 +var embeddedExtension = browserext.Embedded + +// newBrowsersInstallCmd 构造 `sctl browsers install`:把内嵌的 sctl Browser 扩展写进数据目录下的固定目录, +// 用户在浏览器里加载这个目录一次。不经过 daemon;之后由 daemon 的自动更新让扩展跟着 sctl 升级。 +func newBrowsersInstallCmd() *cobra.Command { + return &cobra.Command{ + Use: "install", + Short: "Write the sctl Browser extension bundled in this binary to a fixed directory to load in the browser", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + bundle, err := embeddedExtension() + if err != nil { + return &ExitError{Code: exitError, Message: err.Error()} + } + dir, err := filepath.Abs(paths.BrowserExtensionDir()) + if err != nil { + return &ExitError{Code: exitError, Message: err.Error()} + } + changed, err := bundle.Install(dir) + if err != nil { + return &ExitError{Code: exitError, Message: err.Error()} + } + if outputFormat == outputJSON { + return printValueJSON(struct { + Dir string `json:"dir"` + Version string `json:"version"` + Changed bool `json:"changed"` + }{dir, bundle.Version(), changed}) + } + state := "written to" + if !changed { + state = "already up to date in" + } + fmt.Fprintf(os.Stdout, `sctl Browser %s %s + %s + +To load it once in Chrome (125 or newer) or another Chromium browser: + 1. Open chrome://extensions and turn on Developer mode. + 2. Click "Load unpacked" and choose the directory above + (macOS hides ~/Library in that dialog: press Cmd+Shift+G and paste the path). + 3. Run "sctl connect" and enter the code in the extension's popup. + +The daemon keeps this directory up to date and reloads the extension after sctl upgrades. +`, bundle.Version(), state, dir) + return nil + }, + } +} + func printBrowsersTable(list []control.BrowserInfo) error { if len(list) == 0 { fmt.Fprintln(os.Stdout, "(no paired browser instances)") diff --git a/internal/cli/browsers_install_test.go b/internal/cli/browsers_install_test.go new file mode 100644 index 0000000..ef19444 --- /dev/null +++ b/internal/cli/browsers_install_test.go @@ -0,0 +1,78 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + . "github.com/smartystreets/goconvey/convey" + + "github.com/scriptscat/sctl/internal/pkg/browserext" +) + +// stubEmbeddedExtension 换上一份假的内嵌扩展(或未内嵌),并把数据目录指向临时目录。返回数据目录。 +func stubEmbeddedExtension(t *testing.T, fsys fstest.MapFS) string { + t.Helper() + dir := t.TempDir() + t.Setenv("SCTL_DATA_DIR", dir) + old := embeddedExtension + embeddedExtension = func() (*browserext.Bundle, error) { return browserext.Load(fsys) } + t.Cleanup(func() { embeddedExtension = old }) + return dir +} + +func TestBrowsersInstall(t *testing.T) { + Convey("sctl browsers install 把内嵌扩展写进数据目录下的固定目录", t, func() { + ext := fstest.MapFS{ + "manifest.json": {Data: []byte(`{"version":"0.3.0","version_name":"0.3.0-rc.1"}`)}, + "background.js": {Data: []byte("bg")}, + } + + Convey("首次安装输出目录、版本和加载步骤", func() { + dataDir := stubEmbeddedExtension(t, ext) + code, out, errOut := runCLICapture("browsers", "install") + So(errOut, ShouldBeEmpty) + So(code, ShouldEqual, exitOK) + dir := filepath.Join(dataDir, "browser-extension") + So(out, ShouldContainSubstring, dir) + So(out, ShouldContainSubstring, "0.3.0-rc.1") + So(out, ShouldContainSubstring, "chrome://extensions") + So(out, ShouldContainSubstring, "sctl connect") + b, err := os.ReadFile(filepath.Join(dir, "background.js")) + So(err, ShouldBeNil) + So(string(b), ShouldEqual, "bg") + }) + + Convey("-o json 输出目录、版本和是否改动;重复安装时 changed 为 false", func() { + dataDir := stubEmbeddedExtension(t, ext) + type result struct { + Dir string `json:"dir"` + Version string `json:"version"` + Changed bool `json:"changed"` + } + want := filepath.Join(dataDir, "browser-extension") + + code, out := runCLI("browsers", "install", "-o", "json") + So(code, ShouldEqual, exitOK) + var first result + So(json.Unmarshal([]byte(out), &first), ShouldBeNil) + So(first, ShouldResemble, result{Dir: want, Version: "0.3.0-rc.1", Changed: true}) + + code, out = runCLI("browsers", "install", "-o", "json") + So(code, ShouldEqual, exitOK) + var second result + So(json.Unmarshal([]byte(out), &second), ShouldBeNil) + So(second.Changed, ShouldBeFalse) + }) + + Convey("二进制没有内嵌扩展时退出码 3,指向 Release 下载", func() { + stubEmbeddedExtension(t, fstest.MapFS{".gitkeep": {}}) + code, _, _, err := runCLIResult(strings.NewReader(""), "browsers", "install") + So(code, ShouldEqual, exitError) + So(err.Error(), ShouldContainSubstring, "releases") + }) + }) +} diff --git a/internal/daemon/bridge/connected_test.go b/internal/daemon/bridge/connected_test.go new file mode 100644 index 0000000..6fb07b9 --- /dev/null +++ b/internal/daemon/bridge/connected_test.go @@ -0,0 +1,74 @@ +package bridge + +import ( + "strings" + "testing" + "time" + + "github.com/google/uuid" + . "github.com/smartystreets/goconvey/convey" +) + +type connectedEvent struct { + instanceID string + bundle string +} + +// connectRecorder 是只关心握手完成的监听者。 +type connectRecorder struct { + *recordingListener + connected chan connectedEvent +} + +func (r *connectRecorder) OnInstanceConnected(instanceID, bundle string) { + r.connected <- connectedEvent{instanceID, bundle} +} + +func newConnectRecorder() *connectRecorder { + return &connectRecorder{recordingListener: newRecordingListener(), connected: make(chan connectedEvent, 4)} +} + +func (r *connectRecorder) awaitConnected() connectedEvent { + select { + case ev := <-r.connected: + return ev + case <-time.After(3 * time.Second): + So("OnInstanceConnected not called", ShouldBeEmpty) + return connectedEvent{} + } +} + +func TestInstanceConnectedListener(t *testing.T) { + Convey("浏览器实例完成握手后,实现了 InstanceConnectedListener 的监听者收到实例 ID 和上报的扩展摘要", t, func() { + h := startTestServer(t) + l := newConnectRecorder() + h.srv.AddBrowserListener(l) + key := h.registerBrowser(instanceA, "chrome-0123") + digest := strings.Repeat("ab", 32) + + Convey("上报了 bundle 时原样带上", func() { + e := h.authenticateBrowser(instanceA, key) + params := browserCapabilities("chrome-0123") + params.Peer.Bundle = digest + So(e.declare(params).Error, ShouldBeNil) + So(l.awaitConnected(), ShouldResemble, connectedEvent{instanceA, digest}) + }) + + Convey("没有上报时为空", func() { + h.connectBrowser(instanceA, key, "chrome-0123") + So(l.awaitConnected(), ShouldResemble, connectedEvent{instanceA, ""}) + }) + + Convey("bundle 不是 64 位小写十六进制时关闭连接,不通知", func() { + for _, bad := range []string{strings.Repeat("AB", 32), "abc", strings.Repeat("ab", 33)} { + e := h.authenticateBrowser(instanceA, key) + params := browserCapabilities("chrome-0123") + params.Peer.Bundle = bad + e.writeRequest(methodCapabilities, uuid.NewString(), params) + e.closedByDaemon() + } + settle() + So(len(l.connected), ShouldEqual, 0) + }) + }) +} diff --git a/internal/daemon/bridge/envelope.go b/internal/daemon/bridge/envelope.go index 6475e96..92bb562 100644 --- a/internal/daemon/bridge/envelope.go +++ b/internal/daemon/bridge/envelope.go @@ -104,6 +104,8 @@ type capabilitiesPeer struct { Product string `json:"product,omitempty"` ProductVersion string `json:"productVersion,omitempty"` ExtensionVersion string `json:"extensionVersion,omitempty"` + // Bundle 是 sctl 管理的扩展目录里标记文件记录的内容摘要(见 internal/pkg/browserext);手动加载的扩展不上报。 + Bundle string `json:"bundle,omitempty"` } type cancelParams struct { diff --git a/internal/daemon/bridge/instances.go b/internal/daemon/bridge/instances.go index f1a369f..a01f7e6 100644 --- a/internal/daemon/bridge/instances.go +++ b/internal/daemon/bridge/instances.go @@ -17,6 +17,8 @@ var ( // instanceIDPattern 限定实例 ID 为定长小写 hex:它直接拼进握手 MAC 输入,定长保证拼接无歧义。 instanceIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) namePattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) + // bundlePattern 是扩展内容摘要(SHA-256 的小写 hex),daemon 拿它与内嵌扩展的摘要比较。 + bundlePattern = regexp.MustCompile(`^[0-9a-f]{64}$`) ) // maxPeerTextBytes 限制实例自报的产品与版本文本,它们会原样出现在 CLI 输出里。 @@ -155,7 +157,8 @@ func (s *Server) registerOnline(c *conn) (replaced bool, err error) { func validCapabilitiesPeer(p *capabilitiesPeer) bool { return p != nil && namePattern.MatchString(p.Name) && - validPeerText(p.Product) && validPeerText(p.ProductVersion) && validPeerText(p.ExtensionVersion) + validPeerText(p.Product) && validPeerText(p.ProductVersion) && validPeerText(p.ExtensionVersion) && + (p.Bundle == "" || bundlePattern.MatchString(p.Bundle)) } func validPeerText(s string) bool { diff --git a/internal/daemon/bridge/listener.go b/internal/daemon/bridge/listener.go index d7f386e..c16ebd4 100644 --- a/internal/daemon/bridge/listener.go +++ b/internal/daemon/bridge/listener.go @@ -27,6 +27,13 @@ type InstanceForgottenListener interface { OnInstanceForgotten(instanceID string) } +// InstanceConnectedListener 是 BrowserListener 的可选扩展:实现它的监听者在浏览器实例完成握手(daemon 已回复 +// 能力声明)后收到实例 ID 和它上报的扩展摘要(未上报时为空)。回调在连接的读循环开始之前、不持有任何服务锁时调用, +// 必须立即返回:要调用这个实例时另起 goroutine,否则回复永远读不到。 +type InstanceConnectedListener interface { + OnInstanceConnected(instanceID, bundle string) +} + // SetBrowserListener 设置主浏览器监听者,替换先前通过它设置的那个(不影响 AddBrowserListener 追加的);传 nil 取消。 func (s *Server) SetBrowserListener(l BrowserListener) { s.mu.Lock() @@ -84,6 +91,15 @@ func (s *Server) instanceGone(instanceID string) { } } +// instanceConnected 通知实现了 InstanceConnectedListener 的监听者实例已完成握手;调用方须已释放全部锁。 +func (s *Server) instanceConnected(c *conn) { + for _, l := range s.browserListeners() { + if cl, ok := l.(InstanceConnectedListener); ok { + cl.OnInstanceConnected(c.instanceID, c.peer.Bundle) + } + } +} + // instanceForgotten 通知实现了 InstanceForgottenListener 的监听者实例已被忘记;调用方须已释放全部锁。 func (s *Server) instanceForgotten(instanceID string) { for _, l := range s.browserListeners() { diff --git a/internal/daemon/bridge/server.go b/internal/daemon/bridge/server.go index 2190d53..aa800b7 100644 --- a/internal/daemon/bridge/server.go +++ b/internal/daemon/bridge/server.go @@ -242,6 +242,9 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { return } s.log.Info("extension completed the handshake and is connected", zap.String("peer", string(c.kind))) + if c.kind == protocol.PeerBrowser { + s.instanceConnected(c) + } go c.heartbeatLoop() c.readLoop() } diff --git a/internal/daemon/component.go b/internal/daemon/component.go index 00ed42b..3ab067d 100644 --- a/internal/daemon/component.go +++ b/internal/daemon/component.go @@ -1,15 +1,17 @@ // Package daemon 是 sctl serve 的组装层:一个 cago Component,把可配置监听地址的 // 桥接 WS server(internal/daemon/bridge)、本机控制 API(internal/daemon/controlapi)、 // 页面自动化组件(internal/daemon/page)、原始 CDP 端点(internal/daemon/cdpendpoint)与持久化存储(internal/daemon/store) -// 接到同一个 listener 上。 +// 接到同一个 listener 上;带内嵌扩展的构建还挂上扩展自动更新(internal/daemon/extupdate)。 // 扩展 ↔ daemon 协议见 docs/protocol.md。 package daemon import ( "context" + "errors" "fmt" "net" "net/http" + "path/filepath" "github.com/cago-frame/cago/configs" "github.com/cago-frame/cago/pkg/gogo" @@ -20,8 +22,10 @@ import ( "github.com/scriptscat/sctl/internal/daemon/bridge" "github.com/scriptscat/sctl/internal/daemon/cdpendpoint" "github.com/scriptscat/sctl/internal/daemon/controlapi" + "github.com/scriptscat/sctl/internal/daemon/extupdate" "github.com/scriptscat/sctl/internal/daemon/page" "github.com/scriptscat/sctl/internal/daemon/store" + "github.com/scriptscat/sctl/internal/pkg/browserext" "github.com/scriptscat/sctl/internal/pkg/paths" "github.com/scriptscat/sctl/internal/pkg/protocol" ) @@ -96,6 +100,18 @@ func (b *daemonComponent) StartCancel(ctx context.Context, cancel context.Cancel Log: logger.Ctx(ctx), }) b.srv.AddBrowserListener(endpoints) + // 带内嵌扩展的构建才做自动更新;没有内嵌时 sctl 管理的扩展目录也无从更新。 + if bundle, err := browserext.Embedded(); err == nil { + // 与 sctl browsers install 一样用绝对路径:相对的 --data-dir 在服务管理器下会按另一个工作目录解析。 + dir, err := filepath.Abs(paths.BrowserExtensionDir()) + if err != nil { + return err + } + b.srv.AddBrowserListener(extupdate.New(ctx, b.srv, bundle, dir, logger.Ctx(ctx))) + } else if !errors.Is(err, browserext.ErrNotEmbedded) { + logger.Ctx(ctx).Error("failed to load the embedded sctl Browser extension", zap.Error(err)) + return err + } mux := http.NewServeMux() mux.Handle(cdpendpoint.PathPrefix, endpoints) controlapi.New(b.srv, pages, endpoints, token, logger.Ctx(ctx)).Register(mux) diff --git a/internal/daemon/extupdate/extupdate.go b/internal/daemon/extupdate/extupdate.go new file mode 100644 index 0000000..4608553 --- /dev/null +++ b/internal/daemon/extupdate/extupdate.go @@ -0,0 +1,108 @@ +// Package extupdate 让 sctl 管理的扩展目录跟着 daemon 内嵌的扩展走:浏览器实例握手时上报的扩展摘要与内嵌的不同, +// 就把内嵌扩展写进 daemon 自己的扩展目录,再让这个实例调用 chrome.runtime.reload() 从磁盘重新加载。 +package extupdate + +import ( + "context" + "encoding/json" + "errors" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/scriptscat/sctl/internal/daemon/bridge" + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" +) + +// reloadTimeout 限制等扩展回复 extension.reload 的时间;扩展回复后才重新加载,不会因为重新加载而丢掉回复。 +const reloadTimeout = 10 * time.Second + +// Caller 是 bridge 中按实例 ID 调用的那部分。 +type Caller interface { + CallInstance(ctx context.Context, instanceID string, req bridge.Request) (bridge.Response, error) +} + +// Bundle 是内嵌的扩展(见 internal/pkg/browserext)。 +type Bundle interface { + Digest() string + Install(dir string) (bool, error) +} + +// Updater 是 bridge 的浏览器监听者,只关心握手完成。 +type Updater struct { + ctx context.Context + caller Caller + bundle Bundle + dir string + log *zap.Logger + + mu sync.Mutex + attempted map[string]bool // 本进程已发出过 reload(或正在尝试)的实例 + installMu sync.Mutex // 串行化目录替换:几个实例可能同时握手 + wg sync.WaitGroup // 后台更新,测试用来等它结束 +} + +// New 创建 Updater;ctx 是 daemon 的生命周期,结束后不再发起新的重新加载。 +func New(ctx context.Context, caller Caller, bundle Bundle, dir string, log *zap.Logger) *Updater { + return &Updater{ctx: ctx, caller: caller, bundle: bundle, dir: dir, log: log, attempted: map[string]bool{}} +} + +func (u *Updater) OnNotification(string, string, json.RawMessage) {} + +func (u *Updater) OnInstanceGone(string) {} + +// OnInstanceConnected 在握手完成后检查实例上报的扩展摘要。reload 发出之后同一实例不再尝试:扩展可能是从别的数据目录 +// 加载的,本 daemon 改的是自己的目录,重新加载后它上报的摘要不变,不加限制就会无限重新加载。还没发出 reload 就失败的 +// (写目录出错、实例已离线)下次连上时再试。 +func (u *Updater) OnInstanceConnected(instanceID, bundle string) { + if bundle == "" || bundle == u.bundle.Digest() { + return + } + u.mu.Lock() + if u.attempted[instanceID] { + u.mu.Unlock() + return + } + u.attempted[instanceID] = true + u.mu.Unlock() + // 回调时连接的读循环尚未开始,同步调用会永远等不到回复。 + u.wg.Add(1) + go func() { + defer u.wg.Done() + if !u.update(instanceID, bundle) { + u.mu.Lock() + delete(u.attempted, instanceID) + u.mu.Unlock() + } + }() +} + +// update 更新目录并让实例重新加载,返回 reload 是否已经送达扩展。 +func (u *Updater) update(instanceID, reported string) bool { + log := u.log.With(zap.String("instance", instanceID), zap.String("reported", reported), zap.String("embedded", u.bundle.Digest())) + u.installMu.Lock() + changed, err := u.bundle.Install(u.dir) + u.installMu.Unlock() + if err != nil { + log.Warn("failed to update the sctl Browser extension directory", zap.String("dir", u.dir), zap.Error(err)) + return false + } + ctx, cancel := context.WithTimeout(u.ctx, reloadTimeout) + defer cancel() + resp, err := u.caller.CallInstance(ctx, instanceID, bridge.Request{Action: string(generated.MethodExtensionReload), Input: json.RawMessage(`{}`)}) + switch { + case errors.Is(err, bridge.ErrDisconnected): + // 扩展先回复再重新加载;回复没赶上连接关闭时,reload 多半已经在进行,按已送达处理,免得反复重新加载。 + log.Info("the sctl Browser extension disconnected while asked to reload", zap.String("dir", u.dir)) + return true + case err != nil: + log.Warn("failed to ask the sctl Browser extension to reload", zap.Error(err)) + return false + case !resp.OK: + log.Warn("the sctl Browser extension refused to reload", zap.String("code", resp.Error.Code), zap.String("message", resp.Error.Message)) + return true + } + log.Info("reloading the outdated sctl Browser extension", zap.String("dir", u.dir), zap.Bool("directoryUpdated", changed)) + return true +} diff --git a/internal/daemon/extupdate/extupdate_test.go b/internal/daemon/extupdate/extupdate_test.go new file mode 100644 index 0000000..9dc5f52 --- /dev/null +++ b/internal/daemon/extupdate/extupdate_test.go @@ -0,0 +1,158 @@ +package extupdate + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + . "github.com/smartystreets/goconvey/convey" + "go.uber.org/zap" + + "github.com/scriptscat/sctl/internal/daemon/bridge" + "github.com/scriptscat/sctl/internal/pkg/protocol/generated" +) + +var ( + embeddedDigest = strings.Repeat("e", 64) + staleDigest = strings.Repeat("a", 64) +) + +type call struct { + instanceID string + action string + input string +} + +type fakeCaller struct { + calls chan call + resp bridge.Response + err error +} + +func (f *fakeCaller) CallInstance(_ context.Context, instanceID string, req bridge.Request) (bridge.Response, error) { + f.calls <- call{instanceID, req.Action, string(req.Input)} + return f.resp, f.err +} + +type fakeBundle struct { + mu sync.Mutex + installs []string + err error +} + +func (b *fakeBundle) Digest() string { return embeddedDigest } + +func (b *fakeBundle) Install(dir string) (bool, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.installs = append(b.installs, dir) + return b.err == nil, b.err +} + +func (b *fakeBundle) installCount() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.installs) +} + +func awaitCall(c *fakeCaller) call { + select { + case got := <-c.calls: + return got + case <-time.After(3 * time.Second): + So("extension.reload not called", ShouldBeEmpty) + return call{} + } +} + +// waitAttempt 等后台的那次更新结束。 +func waitAttempt(u *Updater) { + u.wg.Wait() +} + +// noCall 给可能的异步调用留出时间,断言它没有发生。 +func noCall(c *fakeCaller) { + select { + case got := <-c.calls: + So(got, ShouldBeZeroValue) + case <-time.After(300 * time.Millisecond): + } +} + +func TestUpdater(t *testing.T) { + Convey("浏览器实例完成握手后的扩展自动更新", t, func() { + caller := &fakeCaller{calls: make(chan call, 4), resp: bridge.Response{OK: true, Result: json.RawMessage(`{}`)}} + bundle := &fakeBundle{} + u := New(context.Background(), caller, bundle, "/data/browser-extension", zap.NewNop()) + + Convey("上报的摘要与内嵌的不同:先更新扩展目录,再让这个实例重新加载", func() { + u.OnInstanceConnected("inst-a", staleDigest) + got := awaitCall(caller) + So(got, ShouldResemble, call{"inst-a", string(generated.MethodExtensionReload), "{}"}) + So(bundle.installs, ShouldResemble, []string{"/data/browser-extension"}) + }) + + Convey("摘要相同时什么都不做", func() { + u.OnInstanceConnected("inst-a", embeddedDigest) + noCall(caller) + So(bundle.installCount(), ShouldEqual, 0) + }) + + Convey("没有上报摘要(手动加载的扩展)时什么都不做", func() { + u.OnInstanceConnected("inst-a", "") + noCall(caller) + So(bundle.installCount(), ShouldEqual, 0) + }) + + Convey("同一实例、同一份内嵌摘要只尝试一次:重新加载后仍上报旧摘要时不再触发", func() { + u.OnInstanceConnected("inst-a", staleDigest) + awaitCall(caller) + u.OnInstanceConnected("inst-a", staleDigest) + noCall(caller) + So(bundle.installCount(), ShouldEqual, 1) + }) + + Convey("不同实例各自尝试一次", func() { + u.OnInstanceConnected("inst-a", staleDigest) + u.OnInstanceConnected("inst-b", staleDigest) + ids := []string{awaitCall(caller).instanceID, awaitCall(caller).instanceID} + So(ids, ShouldContain, "inst-a") + So(ids, ShouldContain, "inst-b") + }) + + Convey("写扩展目录失败时不让扩展重新加载,下次连上时再试", func() { + bundle.err = errors.New("disk full") + u.OnInstanceConnected("inst-a", staleDigest) + noCall(caller) + So(bundle.installCount(), ShouldEqual, 1) + bundle.mu.Lock() + bundle.err = nil + bundle.mu.Unlock() + u.OnInstanceConnected("inst-a", staleDigest) + So(awaitCall(caller).instanceID, ShouldEqual, "inst-a") + }) + + Convey("实例在发出 reload 前已离线时,下次连上再试", func() { + caller.err = &bridge.Error{Code: generated.ErrorCodeBrowserOffline, Message: "offline"} + u.OnInstanceConnected("inst-a", staleDigest) + awaitCall(caller) + waitAttempt(u) + caller.err = nil + u.OnInstanceConnected("inst-a", staleDigest) + So(awaitCall(caller).instanceID, ShouldEqual, "inst-a") + }) + + Convey("回复前连接就断了(扩展已在重新加载)算作已尝试,不再重试", func() { + caller.err = bridge.ErrDisconnected + u.OnInstanceConnected("inst-a", staleDigest) + awaitCall(caller) + waitAttempt(u) + u.OnInstanceConnected("inst-a", staleDigest) + noCall(caller) + }) + }) +} diff --git a/internal/pkg/browserext/browserext.go b/internal/pkg/browserext/browserext.go new file mode 100644 index 0000000..b04fd0f --- /dev/null +++ b/internal/pkg/browserext/browserext.go @@ -0,0 +1,242 @@ +// Package browserext 携带内嵌在二进制里的 sctl Browser 扩展,并把它写进 sctl 管理的扩展目录。 +// +// dist/ 在源码里只有一个占位文件:扩展产物由 `make extension-embed` 或发布流程在 go build 之前拷进去, +// 所以不装 Node 也能编译和测试,只是得到的二进制不带扩展(Embedded 返回 ErrNotEmbedded)。 +package browserext + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +//go:embed all:dist +var embedded embed.FS + +// MarkerFile 是 sctl 写进扩展目录的标记文件,扩展读取它并在能力声明里上报摘要。 +// 只有 sctl 管理的目录里有它,从 Release zip 手动加载的扩展没有。 +const MarkerFile = "sctl-bundle.json" + +// placeholder 是 dist/ 里提交进版本库的占位文件,不属于扩展。 +const placeholder = ".gitkeep" + +// ErrNotEmbedded 表示这个构建没有内嵌扩展。 +var ErrNotEmbedded = errors.New("this sctl build does not include the sctl Browser extension; " + + "download sctl-browser-extension-.zip from https://github.com/scriptscat/sctl/releases instead") + +// Bundle 是一份扩展产物及其内容摘要。 +type Bundle struct { + fsys fs.FS + files []string // 排序后的文件路径,不含占位文件 + digest string + version string +} + +type marker struct { + Digest string `json:"digest"` +} + +var ( + embeddedOnce sync.Once + embeddedBundle *Bundle + embeddedErr error +) + +// Embedded 返回内嵌的扩展;摘要只计算一次。 +func Embedded() (*Bundle, error) { + embeddedOnce.Do(func() { + sub, err := fs.Sub(embedded, "dist") + if err != nil { + embeddedErr = err + return + } + embeddedBundle, embeddedErr = Load(sub) + }) + return embeddedBundle, embeddedErr +} + +// Load 读取 fsys 根目录下的扩展产物,计算摘要:全部文件按路径排序,依次计入路径和内容。 +func Load(fsys fs.FS) (*Bundle, error) { + raw, err := fs.ReadFile(fsys, "manifest.json") + if errors.Is(err, fs.ErrNotExist) { + return nil, ErrNotEmbedded + } + if err != nil { + return nil, err + } + var manifest struct { + Version string `json:"version"` + VersionName string `json:"version_name"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + return nil, fmt.Errorf("parse embedded manifest.json: %w", err) + } + b := &Bundle{fsys: fsys, version: manifest.Version} + // 预发布后缀只在 version_name 里(Chrome 的 version 只接受数字)。 + if manifest.VersionName != "" { + b.version = manifest.VersionName + } + err = fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + // 标记文件由 sctl 写出,不属于扩展内容;算进去的话,读回已安装目录时摘要永远对不上。 + if !d.IsDir() && p != placeholder && p != MarkerFile { + b.files = append(b.files, p) + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(b.files) + h := sha256.New() + for _, p := range b.files { + body, err := fs.ReadFile(fsys, p) + if err != nil { + return nil, err + } + // 路径和内容都带长度前缀,避免不同的文件划分拼出相同的字节流。 + fmt.Fprintf(h, "%d:%s%d:", len(p), p, len(body)) + h.Write(body) + } + b.digest = hex.EncodeToString(h.Sum(nil)) + return b, nil +} + +// Digest 是这份扩展内容的 SHA-256(64 位小写十六进制)。 +func (b *Bundle) Digest() string { return b.digest } + +// Version 是扩展的版本,带预发布后缀时取 manifest 的 version_name。 +func (b *Bundle) Version() string { return b.version } + +// InstalledDigest 读取 dir 里标记文件记录的摘要;目录或标记文件不存在、标记损坏时返回空串(视为需要重新写入)。 +func InstalledDigest(dir string) (string, error) { + raw, err := os.ReadFile(filepath.Join(dir, MarkerFile)) + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + return parseMarker(raw), nil +} + +// parseMarker 解出标记里的摘要;损坏的标记等同于没有标记,重新写入就是修复。 +func parseMarker(raw []byte) string { + var m marker + if json.Unmarshal(raw, &m) != nil { + return "" + } + return m.Digest +} + +// upToDate 报告 dir 是否已是这份扩展:标记与实际内容都要对上,文件被删改、标记损坏时都需要重新写入。 +func (b *Bundle) upToDate(dir string) (bool, error) { + marked, err := InstalledDigest(dir) + if err != nil || marked != b.digest { + return false, err + } + // 没有 manifest.json 或读不了内容同样视为过期:重新写入就是修复。 + onDisk, err := Load(os.DirFS(dir)) + return err == nil && onDisk.digest == b.digest, nil +} + +// stagingPrefix 是 Install 在同级目录里用的临时目录前缀,中断后遗留的由下一次 Install 清扫。 +const stagingPrefix = ".browser-extension-new-" + +func sweepStaging(parent string) error { + entries, err := os.ReadDir(parent) + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() && strings.HasPrefix(e.Name(), stagingPrefix) { + if err := os.RemoveAll(filepath.Join(parent, e.Name())); err != nil { + return err + } + } + } + return nil +} + +// Install 把扩展写进 dir;dir 里已是同一份内容时不改动并返回 false。 +// +// 目录整体替换:先写到同级的临时目录,再把旧目录移开、新目录就位。扩展文件名带内容哈希,原地覆盖会让 +// 旧文件留下来;整体替换让 dir 在任一时刻要么是完整的旧版、要么是完整的新版。 +func (b *Bundle) Install(dir string) (bool, error) { + current, err := b.upToDate(dir) + if err != nil || current { + return false, err + } + parent := filepath.Dir(dir) + // parent 是数据目录:没建过时按数据目录的惯例只给当前用户,浏览器会以扩展的权限执行这里的代码。 + if err := os.MkdirAll(parent, 0o700); err != nil { + return false, err + } + if err := sweepStaging(parent); err != nil { + return false, err + } + staging, err := os.MkdirTemp(parent, stagingPrefix+"*") + if err != nil { + return false, err + } + defer os.RemoveAll(staging) // 成功时它已被改名为 dir,这里什么也删不到 + if err := b.writeTo(staging); err != nil { + return false, err + } + + old := "" + if _, err := os.Stat(dir); err == nil { + old = staging + ".old" + if err := os.Rename(dir, old); err != nil { + return false, err + } + } else if !errors.Is(err, fs.ErrNotExist) { + return false, err + } + if err := os.Rename(staging, dir); err != nil { + if old != "" { + // 新目录就位失败时把旧目录放回去,浏览器至少还能加载旧版。 + if rerr := os.Rename(old, dir); rerr != nil { + return false, errors.Join(err, rerr) + } + } + return false, err + } + if old != "" { + // 新版已经就位;旧目录删不掉(例如 Windows 上仍有文件被占用)不影响结果,留给下一次 Install 清扫。 + _ = os.RemoveAll(old) + } + return true, nil +} + +func (b *Bundle) writeTo(dir string) error { + for _, p := range b.files { + body, err := fs.ReadFile(b.fsys, p) + if err != nil { + return err + } + target := filepath.Join(dir, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := os.WriteFile(target, body, 0o644); err != nil { + return err + } + } + raw, err := json.Marshal(marker{Digest: b.digest}) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, MarkerFile), raw, 0o644) +} diff --git a/internal/pkg/browserext/browserext_test.go b/internal/pkg/browserext/browserext_test.go new file mode 100644 index 0000000..dec40ea --- /dev/null +++ b/internal/pkg/browserext/browserext_test.go @@ -0,0 +1,176 @@ +package browserext + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "runtime" + "testing" + "testing/fstest" + + . "github.com/smartystreets/goconvey/convey" +) + +func bundleFS(manifest string, extra map[string]string) fstest.MapFS { + fsys := fstest.MapFS{"manifest.json": {Data: []byte(manifest)}} + for name, body := range extra { + fsys[name] = &fstest.MapFile{Data: []byte(body)} + } + return fsys +} + +func TestLoad(t *testing.T) { + Convey("加载内嵌扩展", t, func() { + Convey("没有 manifest.json(只有占位文件)视为未内嵌", func() { + _, err := Load(fstest.MapFS{".gitkeep": {}}) + So(errors.Is(err, ErrNotEmbedded), ShouldBeTrue) + }) + + Convey("摘要只取决于文件路径和内容", func() { + a, err := Load(bundleFS(`{"version":"0.3.0"}`, map[string]string{"assets/a.js": "1"})) + So(err, ShouldBeNil) + same, err := Load(bundleFS(`{"version":"0.3.0"}`, map[string]string{"assets/a.js": "1"})) + So(err, ShouldBeNil) + changed, err := Load(bundleFS(`{"version":"0.3.0"}`, map[string]string{"assets/a.js": "2"})) + So(err, ShouldBeNil) + renamed, err := Load(bundleFS(`{"version":"0.3.0"}`, map[string]string{"assets/b.js": "1"})) + So(err, ShouldBeNil) + + So(regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(a.Digest()), ShouldBeTrue) + So(same.Digest(), ShouldEqual, a.Digest()) + So(changed.Digest(), ShouldNotEqual, a.Digest()) + So(renamed.Digest(), ShouldNotEqual, a.Digest()) + }) + + Convey("版本优先取 version_name(带预发布后缀),否则取 version", func() { + rc, err := Load(bundleFS(`{"version":"0.3.0","version_name":"0.3.0-rc.1"}`, nil)) + So(err, ShouldBeNil) + So(rc.Version(), ShouldEqual, "0.3.0-rc.1") + plain, err := Load(bundleFS(`{"version":"0.3.0"}`, nil)) + So(err, ShouldBeNil) + So(plain.Version(), ShouldEqual, "0.3.0") + }) + + Convey("占位文件不计入摘要也不写出", func() { + with, err := Load(bundleFS(`{"version":"0.3.0"}`, map[string]string{".gitkeep": ""})) + So(err, ShouldBeNil) + without, err := Load(bundleFS(`{"version":"0.3.0"}`, nil)) + So(err, ShouldBeNil) + So(with.Digest(), ShouldEqual, without.Digest()) + }) + }) +} + +func TestInstall(t *testing.T) { + Convey("把扩展写进目录", t, func() { + dir := filepath.Join(t.TempDir(), "data", "browser-extension") + v1, err := Load(bundleFS(`{"version":"0.3.0"}`, map[string]string{"assets/old-1.js": "old"})) + So(err, ShouldBeNil) + v2, err := Load(bundleFS(`{"version":"0.4.0"}`, map[string]string{"assets/new-2.js": "new"})) + So(err, ShouldBeNil) + + Convey("首次安装写出全部文件和记录摘要的标记文件", func() { + changed, err := v1.Install(dir) + So(err, ShouldBeNil) + So(changed, ShouldBeTrue) + b, err := os.ReadFile(filepath.Join(dir, "assets", "old-1.js")) + So(err, ShouldBeNil) + So(string(b), ShouldEqual, "old") + digest, err := InstalledDigest(dir) + So(err, ShouldBeNil) + So(digest, ShouldEqual, v1.Digest()) + }) + + Convey("标记文件完好但文件被删改时重新写入", func() { + _, err := v1.Install(dir) + So(err, ShouldBeNil) + So(os.WriteFile(filepath.Join(dir, "assets", "old-1.js"), nil, 0o644), ShouldBeNil) + changed, err := v1.Install(dir) + So(err, ShouldBeNil) + So(changed, ShouldBeTrue) + b, err := os.ReadFile(filepath.Join(dir, "assets", "old-1.js")) + So(err, ShouldBeNil) + So(string(b), ShouldEqual, "old") + }) + + Convey("标记文件损坏时重新写入,不永久失败", func() { + _, err := v1.Install(dir) + So(err, ShouldBeNil) + So(os.WriteFile(filepath.Join(dir, MarkerFile), []byte("{"), 0o644), ShouldBeNil) + changed, err := v1.Install(dir) + So(err, ShouldBeNil) + So(changed, ShouldBeTrue) + digest, err := InstalledDigest(dir) + So(err, ShouldBeNil) + So(digest, ShouldEqual, v1.Digest()) + }) + + Convey("清扫上次中断遗留的临时目录", func() { + So(os.MkdirAll(filepath.Join(filepath.Dir(dir), ".browser-extension-new-123"), 0o700), ShouldBeNil) + So(os.MkdirAll(filepath.Join(filepath.Dir(dir), ".browser-extension-new-456.old"), 0o700), ShouldBeNil) + _, err := v1.Install(dir) + So(err, ShouldBeNil) + entries, err := os.ReadDir(filepath.Dir(dir)) + So(err, ShouldBeNil) + So(len(entries), ShouldEqual, 1) + }) + + Convey("目录里已是同一份内容时不改动", func() { + _, err := v1.Install(dir) + So(err, ShouldBeNil) + before, err := os.Stat(dir) + So(err, ShouldBeNil) + changed, err := v1.Install(dir) + So(err, ShouldBeNil) + So(changed, ShouldBeFalse) + after, err := os.Stat(dir) + So(err, ShouldBeNil) + So(os.SameFile(before, after), ShouldBeTrue) + }) + + Convey("换一份内容时整体替换:旧文件消失,同级不留临时目录", func() { + _, err := v1.Install(dir) + So(err, ShouldBeNil) + changed, err := v2.Install(dir) + So(err, ShouldBeNil) + So(changed, ShouldBeTrue) + _, statErr := os.Stat(filepath.Join(dir, "assets", "old-1.js")) + So(os.IsNotExist(statErr), ShouldBeTrue) + b, err := os.ReadFile(filepath.Join(dir, "assets", "new-2.js")) + So(err, ShouldBeNil) + So(string(b), ShouldEqual, "new") + digest, err := InstalledDigest(dir) + So(err, ShouldBeNil) + So(digest, ShouldEqual, v2.Digest()) + entries, err := os.ReadDir(filepath.Dir(dir)) + So(err, ShouldBeNil) + So(len(entries), ShouldEqual, 1) + }) + + Convey("数据目录还不存在时按惯例只给当前用户", func() { + if runtime.GOOS == "windows" { + return + } + _, err := v1.Install(dir) + So(err, ShouldBeNil) + info, err := os.Stat(filepath.Dir(dir)) + So(err, ShouldBeNil) + So(info.Mode().Perm(), ShouldEqual, os.FileMode(0o700)) + }) + + Convey("目录不存在、没有标记文件或标记损坏时摘要为空", func() { + digest, err := InstalledDigest(dir) + So(err, ShouldBeNil) + So(digest, ShouldBeEmpty) + So(os.MkdirAll(dir, 0o755), ShouldBeNil) + digest, err = InstalledDigest(dir) + So(err, ShouldBeNil) + So(digest, ShouldBeEmpty) + So(os.WriteFile(filepath.Join(dir, MarkerFile), []byte("{"), 0o644), ShouldBeNil) + digest, err = InstalledDigest(dir) + So(err, ShouldBeNil) + So(digest, ShouldBeEmpty) + }) + }) +} diff --git a/internal/pkg/browserext/dist/.gitkeep b/internal/pkg/browserext/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/pkg/paths/paths.go b/internal/pkg/paths/paths.go index 437e572..497664b 100644 --- a/internal/pkg/paths/paths.go +++ b/internal/pkg/paths/paths.go @@ -53,6 +53,11 @@ func BrowsersFile() string { return filepath.Join(DataDir(), "browsers.json") } +// BrowserExtensionDir 返回 sctl 管理的 sctl Browser 扩展目录:用户在浏览器里加载它,daemon 在扩展落后时原地更新它。 +func BrowserExtensionDir() string { + return filepath.Join(DataDir(), "browser-extension") +} + // ControlTokenFile 返回本机内部控制通道凭据的落盘路径(0600)。daemon 绑定端口后写入, // 同用户的前端(sctl mcp / CLI 动词)读取后作为控制 API 的鉴权凭据。 func ControlTokenFile() string { diff --git a/internal/pkg/protocol/generated/protocol.generated.go b/internal/pkg/protocol/generated/protocol.generated.go index aaf2371..dc66cca 100644 --- a/internal/pkg/protocol/generated/protocol.generated.go +++ b/internal/pkg/protocol/generated/protocol.generated.go @@ -396,6 +396,12 @@ type DownloadsStartResult struct { Id int `json:"id"` } +type ExtensionReloadParams struct { +} + +type ExtensionReloadResult struct { +} + type ExtensionsDisableParams struct { Confirm *bool `json:"confirm,omitempty"` Id string `json:"id"` @@ -899,6 +905,7 @@ const ( MethodDownloadsResume Method = "downloads.resume" MethodDownloadsShow Method = "downloads.show" MethodDownloadsStart Method = "downloads.start" + MethodExtensionReload Method = "extension.reload" MethodExtensionsDisable Method = "extensions.disable" MethodExtensionsEnable Method = "extensions.enable" MethodExtensionsList Method = "extensions.list" @@ -982,6 +989,7 @@ var Methods = map[string]MethodMetadata{ "downloads.resume": {Params: "DownloadsResumeParams", Result: "DownloadsResumeResult", Scope: "downloads:resume", Effect: "write", Blocking: "none", Level: "L0", Peer: "browser", MergeField: "", Internal: false}, "downloads.show": {Params: "DownloadsShowParams", Result: "DownloadsShowResult", Scope: "downloads:show", Effect: "write", Blocking: "none", Level: "L0", Peer: "browser", MergeField: "", Internal: false}, "downloads.start": {Params: "DownloadsStartParams", Result: "DownloadsStartResult", Scope: "downloads:start", Effect: "write", Blocking: "none", Level: "L0", Peer: "browser", MergeField: "", Internal: false}, + "extension.reload": {Params: "ExtensionReloadParams", Result: "ExtensionReloadResult", Scope: "extension:reload", Effect: "write", Blocking: "none", Level: "L0", Peer: "browser", MergeField: "", Internal: true}, "extensions.disable": {Params: "ExtensionsDisableParams", Result: "ExtensionsSetEnabledResult", Scope: "extensions:disable", Effect: "write", Blocking: "none", Level: "L1", Peer: "browser", MergeField: "", Internal: false}, "extensions.enable": {Params: "ExtensionsEnableParams", Result: "ExtensionsSetEnabledResult", Scope: "extensions:enable", Effect: "write", Blocking: "none", Level: "L0", Peer: "browser", MergeField: "", Internal: false}, "extensions.list": {Params: "ExtensionsListParams", Result: "ExtensionsListResult", Scope: "extensions:list", Effect: "read", Blocking: "none", Level: "L0", Peer: "browser", MergeField: "items", Internal: false}, diff --git a/internal/pkg/protocol/protocol.json b/internal/pkg/protocol/protocol.json index c57718f..9fd60a6 100644 --- a/internal/pkg/protocol/protocol.json +++ b/internal/pkg/protocol/protocol.json @@ -714,6 +714,16 @@ "level": "L0", "peer": "browser", "internal": true + }, + "extension.reload": { + "params": "ExtensionReloadParams", + "result": "ExtensionReloadResult", + "scope": "extension:reload", + "effect": "write", + "blocking": "none", + "level": "L0", + "peer": "browser", + "internal": true } }, "notifications": { @@ -3886,6 +3896,16 @@ "tabId" ], "additionalProperties": false + }, + "ExtensionReloadParams": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "ExtensionReloadResult": { + "type": "object", + "properties": {}, + "additionalProperties": false } }, "errorCodes": [ diff --git a/skills/sctl/SKILL.md b/skills/sctl/SKILL.md index 3505878..3815bb3 100644 --- a/skills/sctl/SKILL.md +++ b/skills/sctl/SKILL.md @@ -52,7 +52,8 @@ sctl browsers -o json # paired sctl Browser instances: name, online, product the launchd/systemd service that runs it). Do not start one yourself in the background: it dies with your session and can fight the user's service for the port. - **`NO_BROWSER_CONNECTED`:** no browser is paired or it is closed. Pairing needs the user: they run `sctl connect` - and type the one-time code into the sctl Browser popup. + and type the one-time code into the sctl Browser popup. If the extension is not installed yet, `sctl browsers + install` prints the directory for them to load as an unpacked extension. - **ScriptCat commands fail as not connected:** the user enables external access in ScriptCat's settings and pairs it with a code from `sctl connect`. diff --git a/skills/sctl/references/browser-data.md b/skills/sctl/references/browser-data.md index 1d2f8f6..e77c561 100644 --- a/skills/sctl/references/browser-data.md +++ b/skills/sctl/references/browser-data.md @@ -82,4 +82,5 @@ files. | Command | Notes | |---|---| | `browsers` / `browsers list` | Paired instances: name, ID, online, product and version | +| `browsers install` | Writes the extension bundled in sctl to a fixed directory and prints how to load it; loading and pairing need the user | | `browsers forget ` | Deletes the pairing key and disconnects the instance; it must be paired again afterwards. Only when the user asks |