From fe940ef4c5475c0e4a509380e6e9bfe9b20b6c3a Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:53:48 +0800 Subject: [PATCH 1/9] feat: add Dia tab title extension and Alfred workflow --- .github/workflows/dia-tabs.yml | 83 +++++++ AGENTS.md | 1 + README.MD | 1 + docs/plugins/dia_tabs.md | 127 +++++++++++ docs/release.md | 8 + docs/specs/2026-11-01-dia-tabs-design.md | 177 +++++++++++++++ plugins/dia_tabs/build.sh | 4 + plugins/dia_tabs/extension/background.js | 72 +++++++ plugins/dia_tabs/extension/control-core.js | 30 +++ plugins/dia_tabs/extension/control.css | 39 ++++ plugins/dia_tabs/extension/control.html | 38 ++++ plugins/dia_tabs/extension/control.js | 124 +++++++++++ plugins/dia_tabs/extension/manifest.json | 28 +++ plugins/dia_tabs/extension/state.js | 142 ++++++++++++ plugins/dia_tabs/install.sh | 5 + plugins/dia_tabs/native/build.py | 54 +++++ plugins/dia_tabs/native/host.py | 203 ++++++++++++++++++ plugins/dia_tabs/native/install.py | 128 +++++++++++ plugins/dia_tabs/tests/background.test.mjs | 125 +++++++++++ plugins/dia_tabs/tests/bridge.test.mjs | 46 ++++ plugins/dia_tabs/tests/control.test.mjs | 111 ++++++++++ plugins/dia_tabs/tests/fixtures/alpha.html | 17 ++ plugins/dia_tabs/tests/fixtures/beta.html | 14 ++ plugins/dia_tabs/tests/markdown_cases.json | 32 +++ plugins/dia_tabs/tests/test_install.py | 81 +++++++ plugins/dia_tabs/tests/test_native.py | 203 ++++++++++++++++++ plugins/dia_tabs/tests/test_workflow.py | 125 +++++++++++ plugins/dia_tabs/workflow/info.plist | 135 ++++++++++++ plugins/dia_tabs/workflow/scripts/action.sh | 3 + plugins/dia_tabs/workflow/scripts/dia_tabs.py | 154 +++++++++++++ plugins/dia_tabs/workflow/scripts/list.sh | 3 + 31 files changed, 2313 insertions(+) create mode 100644 .github/workflows/dia-tabs.yml create mode 100644 docs/plugins/dia_tabs.md create mode 100644 docs/specs/2026-11-01-dia-tabs-design.md create mode 100755 plugins/dia_tabs/build.sh create mode 100644 plugins/dia_tabs/extension/background.js create mode 100644 plugins/dia_tabs/extension/control-core.js create mode 100644 plugins/dia_tabs/extension/control.css create mode 100644 plugins/dia_tabs/extension/control.html create mode 100644 plugins/dia_tabs/extension/control.js create mode 100644 plugins/dia_tabs/extension/manifest.json create mode 100644 plugins/dia_tabs/extension/state.js create mode 100755 plugins/dia_tabs/install.sh create mode 100644 plugins/dia_tabs/native/build.py create mode 100644 plugins/dia_tabs/native/host.py create mode 100644 plugins/dia_tabs/native/install.py create mode 100644 plugins/dia_tabs/tests/background.test.mjs create mode 100644 plugins/dia_tabs/tests/bridge.test.mjs create mode 100644 plugins/dia_tabs/tests/control.test.mjs create mode 100644 plugins/dia_tabs/tests/fixtures/alpha.html create mode 100644 plugins/dia_tabs/tests/fixtures/beta.html create mode 100644 plugins/dia_tabs/tests/markdown_cases.json create mode 100644 plugins/dia_tabs/tests/test_install.py create mode 100644 plugins/dia_tabs/tests/test_native.py create mode 100644 plugins/dia_tabs/tests/test_workflow.py create mode 100644 plugins/dia_tabs/workflow/info.plist create mode 100755 plugins/dia_tabs/workflow/scripts/action.sh create mode 100644 plugins/dia_tabs/workflow/scripts/dia_tabs.py create mode 100755 plugins/dia_tabs/workflow/scripts/list.sh diff --git a/.github/workflows/dia-tabs.yml b/.github/workflows/dia-tabs.yml new file mode 100644 index 0000000..08306f5 --- /dev/null +++ b/.github/workflows/dia-tabs.yml @@ -0,0 +1,83 @@ +name: Dia Tabs + +on: + pull_request: + paths: + - '.github/workflows/dia-tabs.yml' + - 'plugins/dia_tabs/**' + - 'docs/plugins/dia_tabs.md' + - 'docs/specs/2026-11-01-dia-tabs-design.md' + - 'docs/release.md' + - 'README.MD' + - 'AGENTS.md' + push: + branches: [master] + tags: ['v*'] + paths: + - '.github/workflows/dia-tabs.yml' + - 'plugins/dia_tabs/**' + +permissions: + contents: read + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Check syntax and plist + run: | + bash -n plugins/dia_tabs/build.sh plugins/dia_tabs/install.sh plugins/dia_tabs/workflow/scripts/*.sh + plutil -lint plugins/dia_tabs/workflow/info.plist + for file in plugins/dia_tabs/extension/*.js; do node --input-type=module --check < "$file"; done + python3 - <<'PY' + import ast + from pathlib import Path + for path in Path('plugins/dia_tabs').rglob('*.py'): + ast.parse(path.read_text(), filename=str(path)) + PY + - name: Test extension and native bridge + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + node --test plugins/dia_tabs/tests/*.test.mjs + python3 -m unittest discover -s plugins/dia_tabs/tests -p 'test_*.py' -v + - name: Build and inspect archives + run: | + version=ci + if [[ "$GITHUB_REF" == refs/tags/* ]]; then version="$GITHUB_REF_NAME"; fi + plugins/dia_tabs/build.sh "$RUNNER_TEMP/dia-tabs" "$version" + for file in "$RUNNER_TEMP/dia-tabs/"*; do unzip -t "$file"; done + - uses: actions/upload-artifact@v4 + with: + name: dia-tabs + path: ${{ runner.temp }}/dia-tabs/* + if-no-files-found: error + + publish: + if: startsWith(github.ref, 'refs/tags/v') + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dia-tabs + path: artifacts + - name: Attach Dia Tabs assets to release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" --title "$TAG" --generate-notes || gh release view "$TAG" >/dev/null + fi + gh release upload "$TAG" artifacts/* --clobber diff --git a/AGENTS.md b/AGENTS.md index 78df30e..cbe8abd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ - `cli/video_subtitle/`:单视频自动生成中文字幕工具,详细说明见 `docs/cli/video_subtitle.md`。 - `emby_plugins/video_subtitle/`:Emby Server 插件,后端调用 `video_subtitle` 生成字幕,详细说明见 `docs/plugins/emby_video_subtitle.md`。 - `plugins/alfred_remote_upload/`:Alfred 5 Workflow,将剪贴板中的图片或 Finder 单文件上传到 SSH 主机,详细说明见 `docs/plugins/alfred_remote_upload.md`。 +- `plugins/dia_tabs/`:Dia MV3 扩展、Python Native Messaging 桥接和 Alfred Workflow,独立构建/安装,说明见 `docs/plugins/dia_tabs.md`;修改时运行 Node/Python 测试、shell/plist 校验及 `plugins/dia_tabs/build.sh`,不进入 Go 构建,CI/3 个 tag 资产由 `.github/workflows/dia-tabs.yml` 管理。 - `plugins/unlanzi_d200x/command_executor/`:Ulanzi D200X 命令执行器,源码、测试和构建入口均在该目录,文档索引见 `plugins/unlanzi_d200x/docs/README.md`。 - `gui/interview_timer/`:macOS 面试悬浮计时 GUI 应用,说明见 `docs/gui/interview_timer.md`。 - `docs/cli/`:CLI 工具文档。 diff --git a/README.MD b/README.MD index a347ecc..b3ad1c0 100644 --- a/README.MD +++ b/README.MD @@ -61,6 +61,7 @@ | CLI | `video_subtitle` | `cli/video_subtitle/` | `video_subtitle` | 为单个视频生成简体中文字幕 | [docs/cli/video_subtitle.md](docs/cli/video_subtitle.md) | | Experimental CLI | `codex_inspector` | `cli/codex_inspector/` | `codex_inspector` | 本机只读查看 Codex 会话、token 用量、活跃度和记忆内容 | [docs/cli/codex_inspector.md](docs/cli/codex_inspector.md) | | Plugin | Alfred Remote Upload | `plugins/alfred_remote_upload/` | `Remote Upload.alfredworkflow` | 将剪贴板图片或 Finder 单文件上传到 SSH 主机,并复制远端路径 | [docs/plugins/alfred_remote_upload.md](docs/plugins/alfred_remote_upload.md) | +| Plugin | Dia Tabs | `plugins/dia_tabs/` | MV3 扩展 + `Dia Tabs.alfredworkflow` | 保存原始网页标题,批量复制链接,通过 Alfred 搜索并精确激活已有标签 | [docs/plugins/dia_tabs.md](docs/plugins/dia_tabs.md) | | Plugin | Ulanzi D200X Command Executor | `plugins/unlanzi_d200x/command_executor/` | `.ulanziPlugin` | 每个按键独立配置并执行 macOS Shell 命令 | [插件文档](plugins/unlanzi_d200x/docs/README.md) | | Plugin | Emby 字幕插件 | `emby_plugins/video_subtitle/` | `LifeTools.Emby.VideoSubtitle.Emby.dll` | 在 Emby 后台调用 `video_subtitle` 生成字幕 | [docs/plugins/emby_video_subtitle.md](docs/plugins/emby_video_subtitle.md) | | GUI | InterviewTimer | `gui/interview_timer/` | `InterviewTimer.app` | macOS 面试悬浮计时器 | [docs/gui/interview_timer.md](docs/gui/interview_timer.md) | diff --git a/docs/plugins/dia_tabs.md b/docs/plugins/dia_tabs.md new file mode 100644 index 0000000..f471cee --- /dev/null +++ b/docs/plugins/dia_tabs.md @@ -0,0 +1,127 @@ +# Dia 网页标题与标签 + +Dia Tabs 保存打开网页的原始 `document.title`,在扩展控制页和 Alfred 中共用同一份数据。Dia 中的自定义标签名不会被当作网页标题;选择已有标签时按标签 ID 激活,不按 URL 查找,也不会另开一个相同页面。 + +适用环境:macOS、支持 MV3 扩展与 Native Messaging 的 Dia、Alfred 5(Workflow 需要 Powerpack)、Python 3.9+。运行时只使用浏览器 API、Python 标准库和 macOS `pbcopy`;Node 24 仅用于开发测试。 + +## 能做什么 + +| 入口 | 操作 | 结果 | +|---|---|---| +| 扩展控制页 | 点击标签标题或 URL | 激活该已有标签及其窗口 | +| 扩展控制页 | 单击行、Cmd/Ctrl 切换、Shift 连选 | 选择当前列表中的一项或多项 | +| 扩展控制页 | 复制所选 URL / Markdown | 按列表顺序输出,一项一行;同 URL 的多个标签保留多项 | +| Alfred | `dia list` | 汇总已连接扩展的各 profile 当前网页 | +| Alfred | `dia list 关键词` | 按原始标题和 URL 搜索,不区分大小写 | +| Alfred | 回车 | 精确激活这条结果对应的标签 | +| Alfred | Cmd+回车 / Alt+回车 | 分别复制 URL / Markdown | + +控制页只显示当前 profile。Alfred 每条结果附连接短标识以区分 profile;只展示能够实时响应的连接,不保存历史列表。 + +## 安装 + +先构建安装文件,再将运行文件安装到稳定目录。不要从临时 worktree 直接注册 native host。 + +```bash +plugins/dia_tabs/build.sh +plugins/dia_tabs/install.sh +``` + +安装器默认使用当前 `python3` 的绝对路径生成 launcher。Alfred 和 Dia 无需加载交互 shell 配置。也可以明确指定 Python 和 Alfred 的 workflows 父目录: + +```bash +PYTHON=/absolute/path/to/python3 plugins/dia_tabs/install.sh \ + --python /absolute/path/to/python3 \ + --workflow-dir "$HOME/Library/Application Support/Alfred/Alfred.alfredpreferences/workflows" +``` + +若 Alfred 设置了同步目录,`--workflow-dir` 应指向同步目录下的 `Alfred.alfredpreferences/workflows`。只会创建或更新 `user.workflow.life-tools-dia-tabs`,不会替换其他 Workflow。省略此参数时,在稳定 runtime 安装后双击构建生成的 `.alfredworkflow` 导入。 + +安装器不运行 sudo、不重启 Dia、不自动打开浏览器页面,也不自动加载扩展。 + +| 路径 | 用途 | +|---|---| +| `~/.local/share/life-tools-dia-tabs/extension` | 在 Dia 中加载的解压扩展目录 | +| `~/.local/share/life-tools-dia-tabs/native-host` | Native Messaging launcher,使用固定 Python 绝对路径 | +| `~/.local/share/life-tools-dia-tabs/dia-tabs` | Alfred 调用的命令入口 | +| `~/.local/share/life-tools-dia-tabs/run/*.sock` | 活跃连接的私有 Unix socket | +| `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.mcoder2014.life_tools.dia_tabs.json` | 默认 native host 注册文件,已在 Dia 1.55.1 验证 | + +2026-11-01 在本机 Dia 1.55.1 实测:注册到 `Dia/User Data/NativeMessagingHosts` 时返回 `Specified native messaging host not found`;改为上述 `Google/Chrome/NativeMessagingHosts` 后成功连接,Alfred 可以实时列出网页。因此默认采用本机已验证的位置。[AdGuard 的浏览器兼容性配置](https://github.com/AdguardTeam/CompatibilityIssues/blob/master/mac/browsers.json) 将 Dia 列为前一种目录,与本机结果不一致;以目标 Dia 的实际连通结果为准。不同 Dia 版本若使用其他目录,用 `--native-host-dir` 明确覆盖。安装器每次只写一个目录,不向多个浏览器目录散装 manifest。 + +```bash +plugins/dia_tabs/install.sh \ + --native-host-dir "$HOME/path/to/NativeMessagingHosts" +``` + +在 Dia 的扩展管理页打开开发者模式,加载安装器输出的稳定 `extension` 目录。固定扩展 ID 为 `mcalepoloegbbciipaklojhcapcghdpd`;native manifest 的 `allowed_origins` 仅允许此 ID。点击工具栏扩展按钮打开控制页,检查“本机桥接已连接”;必要时点击“重新连接”。桥接不可用时,控制页列表和复制仍可独立使用。 + +默认 `--prefix` 为上述稳定目录。自定义时路径必须足够短,完整 socket 路径(UTF-8 字节)小于 104 字节;安装器会在写入前检查。使用自定义 prefix 时,同时指定 `--workflow-dir` 以写入正确 runtime 路径,或在 Alfred Workflow 的变量 `DIA_TABS_HOME` 中填写该绝对路径。 + +## 标题何时更新 + +只在标签是所在窗口的 active 标签、且加载完成时读取顶层网页 DOM;首次安装不会遍历访问所有后台网页。多个窗口各自选中的标签都可以采集。缓存存于 `chrome.storage.session`,浏览器重启或扩展重载后重新采集,不建立网页历史数据库。 + +| 状态 | 列表显示 | 复制 Markdown 的链接文字 | +|---|---|---| +| 已采集原始标题 | 网页原始标题 | 原始标题 | +| 还未访问 | 待访问 | URL | +| 网页原始标题为空 | 无标题 | URL | +| 受限页面或读取失败 | 无法读取页面标题 | URL | + +页面开始加载、导航或 URL 改变时作废旧标题。页面 active 且加载完成后重新读取;页面动态修改标题时继续更新。后台标签保留已采集且仍对应当前 URL 的标题,后台导航会清空旧标题。关闭标签后从列表移除;关闭后的延迟采集响应不会恢复旧记录。 + +仅列出 HTTP/HTTPS 网页。不支持隐私窗口,不读取 `file://`、`chrome://`、`dia://` 或扩展页面。Markdown 转义反斜线和方括号,标题换行归为空格;URL 中的空格、控制字符、括号、尖括号和反斜线做百分号编码,保留已有百分号编码。 + +## 本机桥接与排障 + +每个扩展 native 连接创建一个随机 socket,目录权限 `0700`、socket 权限 `0600`。host 只转发当前请求,不保存网页快照;标准输入断开即移除自身 socket。崩溃遗留的 socket 不会被当作历史数据展示。最多并发 32 个请求,单消息上限 1 MiB,默认 host 响应超时 2 秒;Alfred 并行查询连接,忽略不可用连接。 + +| 现象 | 检查与处理 | +|---|---| +| 控制页正常,Alfred 显示桥接未连接 | 检查 native manifest 目录与 `path`;确认安装时指定的 Python 仍存在,再点“重新连接” | +| 扩展显示 native host 未找到 | 核对注册目录是否适用于本机 Dia;不要同时向多个目录重复注册 | +| Alfred 查询不到某个 profile | 在该 profile 启用扩展并确认桥接连接;隐私窗口不支持 | +| 结果显示“待访问” | 切到该网页并等待加载完成,再刷新列表 | +| 回车后标签未激活 | 标签可能已关闭,或浏览器会话已变化;重新执行 `dia list`,不会用 URL 新开标签兜底 | +| 复制失败 | 查看 Alfred Workflow debugger;`pbcopy` 失败返回非零状态,不报告复制成功 | +| 移动或删除了安装时的 Python | 使用新的 `--python` 路径重新运行安装器 | + +可以在终端只读查询桥接,不需要启动 Alfred: + +```bash +"$HOME/.local/share/life-tools-dia-tabs/dia-tabs" list 'list' +``` + +更新时重新构建和运行安装器,再在 Dia 扩展管理页重新加载扩展。卸载时先在 Dia 移除该扩展,在 Alfred 中删除 Dia Tabs Workflow,再删除安装器输出的本工具 native manifest 和稳定安装目录。只删除 `com.mcoder2014.life_tools.dia_tabs.json`,不要清空整个 NativeMessagingHosts 或 workflows 目录。`install-state.json` 记录本工具本次的安装路径,可用于核对归属。 + +## 构建、验证和发布 + +```bash +node --test plugins/dia_tabs/tests/*.test.mjs +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s plugins/dia_tabs/tests -p 'test_*.py' -v +bash -n plugins/dia_tabs/build.sh plugins/dia_tabs/install.sh plugins/dia_tabs/workflow/scripts/*.sh +plutil -lint plugins/dia_tabs/workflow/info.plist +plugins/dia_tabs/build.sh /tmp/dia-tabs-build dev +for file in /tmp/dia-tabs-build/*; do unzip -t "$file"; done +``` + +Python 集成测试需要在临时目录创建 Unix socket,不操作真实浏览器或系统剪贴板;复制测试拦截 `pbcopy` 并检查 stdin。自动化测试覆盖并发请求 ID 隔离、分片帧、UTF-8、超时、EOF 清理、陈旧 socket、关键词和修饰键参数、两端相同的 Markdown fixtures,以及临时安装与 zip 内容。 + +| 产物 | 内容 | +|---|---| +| `life_tools_dia_tabs_extension_<版本>.zip` | 解压扩展,内含根目录 `manifest.json` | +| `life_tools_dia_tabs_<版本>.alfredworkflow` | 可导入 Alfred 的工作流;需要先安装稳定 runtime | +| `life_tools_dia_tabs_source_<版本>.zip` | 扩展、本机 Python 源码、工作流、构建/安装脚本和本说明 | + +构建包不包含 socket、用户配置、浏览器列表或标题缓存。解压 source 包后可直接运行其 `install.sh`。`.github/workflows/dia-tabs.yml` 独立执行 macOS 上的 Node/Python/shell/plist/包校验;PR 只上传验证包,`v*` tag 通过后附加以上 3 个版本化资产,不修改现有 Go 发布包。 + +离线验证不能替代 Dia/Alfred 实机验收。2026-11-01 本机 Dia 1.55.1、Alfred 5 的状态如下: + +| 范围 | 实机状态 | +|---|---| +| Native 桥接与 Alfred 列表 | Chrome 用户目录注册成功;Alfred 搜索可以显示演示网页 | +| DOM 标题与控制页 | 动态标题由 Alpha 更新为包含方括号和中文的标题;点击 URL 返回同一标签;多选 Markdown 在真实文本框粘贴准确;关闭演示标签后列表由 1 项变为 0 项 | +| 待验证 | 手动重命名后动态标题、跨窗口/最小化、浏览器退出后的清理、Alfred 回车实际切换及 Cmd/Alt+回车实际复制 | + +Alfred 修饰键复制已通过参数和 `pbcopy` 的离线测试;组合键的实机交互尚未确认,不能据此声称验收完成。 diff --git a/docs/release.md b/docs/release.md index be1326f..36da614 100644 --- a/docs/release.md +++ b/docs/release.md @@ -20,6 +20,7 @@ workflow 文件: .github/workflows/swift-mac-app.yml .github/workflows/alfred-workflow.yml .github/workflows/ulanzi-command-executor.yml +.github/workflows/dia-tabs.yml ``` 触发条件: @@ -59,6 +60,8 @@ Go 测试由 `.github/workflows/go-test.yml` 单独执行,失败会阻塞 PR `ulanzi-command-executor.yml` 使用 `macos-latest` runner,为插件改动提供快速测试和打包反馈。tag 发布时由 `release.yml` 的 `Build Ulanzi release asset` job 重新构建版本化安装包,并由统一的 publish job 写入 Release。 +`dia-tabs.yml` 独立在 macOS 验证 MV3 扩展 Node 测试、Python native/Alfred 协议测试、临时安装、shell/plist 和 zip;PR 上传 dry-run 包,`v*` tag 验证通过后由该 workflow 自己附加 3 个 Dia Tabs 资产。它不进入根目录 Go 构建或安装流程,也不改变 `release.yml`。 + ## 发布操作流程 1. 先把发布相关 PR 合并到 `master`。 @@ -93,6 +96,9 @@ life_tools_alfred_remote_upload_.alfredworkflow life_tools_ulanzi_d200x_command_executor_.zip life_tools_interview_timer_macos_.zip life_tools_interview_timer_macos_.sha256 +life_tools_dia_tabs_extension_.zip +life_tools_dia_tabs_.alfredworkflow +life_tools_dia_tabs_source_.zip checksums.txt ``` @@ -134,6 +140,8 @@ InterviewTimer.app Alfred 产物是可直接导入 Alfred 5 的 `life_tools_alfred_remote_upload_.alfredworkflow`。用户的 `hosts_json` 和 MRU 状态由 Alfred 及其 workflow data 目录管理,不进入发布包。 +Dia Tabs 产物包含解压扩展 zip、Alfred Workflow 和可移植 Python 源码安装包;运行时使用 Python 3.9+ 标准库,不是免依赖二进制。必须先将 native runtime 安装到稳定目录,再加载扩展和导入 Workflow。具体安装和原始网页标题语义见 [Dia Tabs 说明](plugins/dia_tabs.md)。这些由独立 workflow 上传的 3 个资产不属于 `release.yml` 生成的 `checksums.txt`。 + Ulanzi 产物 `life_tools_ulanzi_d200x_command_executor_.zip` 的最外层是 `com.ulanzi.commandexecutor.ulanziPlugin`,可按 [Ulanzi D200X 命令执行器安装说明](../plugins/unlanzi_d200x/docs/command-executor-installation.md) 解压到 Studio 插件目录。历史 Release 不会因 workflow 更新自动重建;只有包含该发布逻辑的新 tag 会自动附带此资产。 ## CI 验证 diff --git a/docs/specs/2026-11-01-dia-tabs-design.md b/docs/specs/2026-11-01-dia-tabs-design.md new file mode 100644 index 0000000..e126f82 --- /dev/null +++ b/docs/specs/2026-11-01-dia-tabs-design.md @@ -0,0 +1,177 @@ +# Dia Tabs 设计 + +状态:实现方案已确定;Dia 1.55.1 的 Native Messaging 注册目录、Alfred 实时列表及部分控制页交互已实机验证;剩余状态见使用说明。 + +## 目标与范围 + +在 Dia 中保存当前打开网页的原始 `document.title`,避免用户重命名标签后污染复制结果。扩展控制页和 Alfred 使用同一份标题数据;激活已有标签时使用标签 ID,不按 URL 查找,也不新建标签。 + +仅新增 `plugins/dia_tabs/` 及相关文档、CI。沿用仓库独立 Alfred 插件的打包方式,不修改现有工具、不进入 Go 构建、不读取 Dia 私有数据库。运行时只用浏览器扩展 API、Python 3 标准库和 macOS 剪贴板命令。 + +## 核心判断与方案比较 + +值得做。网页标题与浏览器自定义标签名是两种数据,必须从页面 DOM 获取前者;同 URL 的两个标签是不同对象,不能用 URL 代替标签身份。 + +| 方案 | 成本与限制 | 判断 | +|---|---|---| +| MV3 扩展 + Native Messaging + Unix socket + Alfred | 一个由浏览器管理生命周期的 Python 进程;注册目录已在 Dia 1.55.1 验证 | 推荐。共享一个标题源,无 TCP 监听、无额外守护服务 | +| 扩展 + localhost HTTP/WebSocket 服务 | 需要常驻服务、端口、认证、启动管理和重连 | 不采用,当前需求不需要这些概念 | +| 扩展和 Alfred 分别用浏览器 API、AppleScript | Dia 的 scripting dictionary 有 tab ID,但两端标题缓存分离;执行网页脚本还可能要求开启 Apple Events JavaScript | 不采用为主路径;只有精确激活 API 实测失败时才评估 AppleScript 补充 | + +最小数据关系是“一个浏览器会话内,tabId 对应一个当前页面标题”。关闭标签就删除,浏览器重启就重新采集,不建设历史记录库。 + +## 组件与数据流 + +| 组件 | 责任 | 不承担的责任 | +|---|---|---| +| MV3 service worker | 查询标签、采集 DOM 标题、维护会话缓存、激活标签 | 不保存浏览历史、不使用 tabs.title 作为网页标题 | +| 扩展控制页 | 显示本 profile 打开的网页、选择与复制 | 不直接访问本机文件 | +| Python native host | 在浏览器 stdio 与 Unix socket 之间转发请求和响应 | 不保存标题快照、不启动 HTTP 服务 | +| Alfred 脚本 | 查询所有活跃 socket,过滤结果,发送激活请求或复制文本 | 不按 URL 猜标签、不扫描 Dia 数据库 | + +控制页直接通过 `runtime.sendMessage` 请求 service worker。Alfred 经 Unix socket → native host → `connectNative` port 请求同一个 service worker。每次列表请求重新查询 `tabs.query({})`,与缓存合并,因此列表成员以当前打开标签为准。 + +每个 native host 连接创建一个随机命名的 socket。不同 profile 自然对应不同 host/连接;不引入中央路由进程。控制页仅展示自身 profile;Alfred 汇总所有能及时响应的连接,附连接短标识用于区分。未安装或禁用扩展的 profile 无法被此工作流列出。 + +## 标签与标题语义 + +`active` 定义为 Chrome Tabs API 的 `tab.active`:窗口内当前选中的标签,不要求窗口正处于前台。因此多个窗口可以各有一个 active 标签。这与 `activeTab` 权限不同,后者需要用户显式调用扩展,不能满足自动记录。 + +| 状态或事件 | 行为 | +|---|---| +| 打开的 HTTP/HTTPS 标签尚未被采集 | 显示“待访问”,保留 URL,可复制 URL、可激活 | +| active 且 tab.status=complete | 在顶层 frame 读取 `document.title`、`location.href`、`document.readyState` | +| 初次启动、`onActivated`、`onUpdated` 的 complete/title 变化 | 满足上述条件时重新采集,不使用事件中的 title 值 | +| 非 active 标签 | 不主动采集;保留其已采集且仍对应当前 URL 的标题 | +| 加载开始或 URL 变化 | 立即作废旧标题,增加该 tab 的采集版本;禁止旧标题与新 URL 配对 | +| SPA 改标题 | active 时由 `onUpdated` 的 title 变化触发重新读取 DOM | +| SPA 改 URL | 作废旧标题,active 且 complete 时重新读取;后续 title 事件继续更新 | +| 页面为空标题 | 显示“无标题”;Markdown 使用 URL 作为链接文本,不回退到 tabs.title | +| 受限页面或执行脚本失败 | 保留 URL,显示“无法读取页面标题”;不得伪装采集成功 | +| 标签关闭或被替换 | 删除旧 tabId 缓存;新 tabId 按新标签处理 | + +控制页和 Alfred 默认只列 HTTP/HTTPS 网页。`chrome://`、`dia://`、扩展自身页面、`file://` 不进入列表;不申请 file 权限。隐私窗口明确不支持:manifest 使用 `incognito: "not_allowed"`,并在查询结果再次过滤 incognito。 + +标题采集不承诺存在一个永远稳定的标题。页面可以持续修改 title;记录的是最后一次满足条件时读到的 DOM title。可以用约 150 毫秒合并同一标签的密集更新,但不能做无期限“等待稳定”。Dia 重命名后是否仍投递 DOM title 的变化事件需要实测;若被 Dia 屏蔽,必须补顶层 DOM title 的 MutationObserver 通知后重新采样,不能悄悄丢失更新。 + +## 状态与并发边界 + +标题缓存放 `chrome.storage.session`,与浏览器会话同生命周期,跨 service worker 重启保留;不放 `storage.sync` 或磁盘 JSON。sessionId 也放在 session storage。浏览器重新启动、扩展重载或升级后重新采集,避免旧 tabId 复用导致错误匹配。 + +记录至少包含 `tabId, url, title, titleState`。列表输出额外包含实时 `windowId, index, active`。导航版本计数用于拒绝延迟返回,不必对外暴露。 + +采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、版本和 URL 均相同、DOM readyState 为 complete 时接受。导航事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 + +service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录。不能只依赖 `onStartup`:休眠后重启 worker 同样要初始化。 + +`connectNative` 保持连接以支持 Alfred 主动请求。断线后用有限重试与 `alarms` 恢复,重试间隔不低于数秒,长期失败最多每分钟一次;不得循环立即重连。控制页应显示本机桥接是否连接。即使 native host 不可用,扩展自身列表和复制仍正常工作。 + +## 精确激活与请求协议 + +每条 Alfred 结果携带 `{connectionId, sessionId, tabId}`。connectionId 是 socket 文件名中的随机 ID;sessionId 由扩展会话产生。URL 与标题只作为展示和复制数据,不能参与查找目标。 + +激活顺序:验证 sessionId → `tabs.get(tabId)` → `tabs.update(tabId, {active:true})` → 用实时 windowId 调用 `windows.update(windowId, {focused:true})`。最小化窗口需要先恢复正常状态;已经最大化的窗口不要强行恢复。标签在列表显示后关闭时返回“标签已关闭”,不得通过打开 URL 兜底。移动窗口后使用新的 windowId。 + +Native host 名称固定为 `com.mcoder2014.life_tools.dia_tabs`。协议使用 JSON,固定版本 `v:1` 和字符串请求 `id`。只接受 `list`、`activate` 两个操作,拒绝任意脚本和任意文件路径。控制页 runtime message 与 native 请求共用此协议。 + +```json +{"v":1,"id":"request-uuid","op":"list"} +{"v":1,"id":"request-uuid","ok":true,"sessionId":"session-uuid","tabs":[]} +{"v":1,"id":"request-uuid","op":"activate","sessionId":"session-uuid","tabId":123} +{"v":1,"id":"request-uuid","ok":false,"error":"TAB_CLOSED"} +``` + +`tabs` 每项字段固定为 `{tabId:number, windowId:number, index:number, url:string, title:string, titleState:"captured"|"pending"|"unavailable", active:boolean}`。空 title 且 captured 表示无标题;pending 表示未采集。成功激活响应为 `{v:1,id,ok:true}`。失败允许 `TAB_CLOSED`、`STALE_SESSION`、`BAD_REQUEST`、`INTERNAL_ERROR`,可附 `message:string`;不得把内部 traceback 发给页面。native host 可以在连接/超时时补 `BRIDGE_UNAVAILABLE`、`TIMEOUT` 错误。 + +Unix socket 一次连接一个请求,使用换行分隔 JSON,设置读取超时与消息上限。native stdio 按 Chrome 协议使用 4 字节本机字节序长度 + UTF-8 JSON;stdout 只能写协议,诊断走 stderr,不记录真实 title/URL。host 为并发客户端分配内部请求 ID,不能直接信任客户端 id 的全局唯一性。超时或断线必须解除等待,不遗留 pending 请求。 + +socket 放在用户目录的私有短路径,例如 `~/.local/share/life-tools-dia-tabs/run/.sock`,父目录 `0700`,socket `0600`;绑定前检查完整路径满足 macOS Unix socket 路径长度限制。native host 读到 stdin EOF 即关闭 socket 并退出。Alfred 只展示当前能响应 list 的连接,忽略崩溃遗留 socket;不依赖 PID 文件或磁盘快照判断浏览器在线。普通关闭浏览器后即使有遗留文件,也不会出现旧列表。 + +## 控制页和 Alfred 交互 + +控制页使用表格,列为选择框、网页标题、URL、状态/窗口和复制 URL 操作。标题和 URL 均可点击,按 tabId 精确激活已有标签;URL 使用链接样式按钮,或在 anchor 上 preventDefault,阻止浏览器默认导航和新建标签。整行作为选择区域:普通点击单选,Ctrl/Cmd 点击切换选择,Shift 点击选择当前显示顺序中的连续区间;带修饰键点击标题或 URL 仍只执行选择,不得激活目标标签。复制按钮阻止事件冒泡。 + +提供“复制所选 URL”和“复制所选 Markdown”。按当前表格顺序输出,每项一行;不同 tab 即使同 URL 也保留为不同项。刷新时按 tabId 保留仍存在的选择,移除已关闭项。无已采集标题的条目使用 URL 作为 Markdown 文本,不把“待访问”写入结果。 + +Alfred keyword 为 `dia`,参数先识别 `list`,`dia list` 展示全部,`dia list <查询>` 按 title 和 URL 进行不区分大小写的过滤。无参数时返回可自动补全到 `dia list ` 的入口。使用 Script Filter JSON 的 `mods`:回车激活,Cmd+回车复制 URL,Alt+回车复制 Markdown。所有动作以 JSON 参数通过 argv 传递,不把 title/URL 插进 shell 代码。复制用 `/usr/bin/pbcopy` 的 stdin。 + +扩展和 Alfred 的 Markdown 转义规则必须一致:标题中的换行归一为空格、转义反斜线及方括号;URL 中会破坏链接语法的空格、括号、尖括号和控制字符使用百分号编码,保留原有合法百分号编码。页面渲染用 textContent,不用网页 title 拼接 HTML。两端共用测试用例数据来验证一致性,无需为此增加公共运行库。 + +## 权限、文件与安装 + +扩展权限为 `tabs`、`scripting`、`storage`、`nativeMessaging`、`alarms`、`clipboardWrite`,host permissions 仅 `http://*/*` 和 `https://*/*`。不申请 debugger、history、cookies、webRequest 或 externally_connectable。 + +建议文件结构: + +```text +plugins/dia_tabs/ + extension/manifest.json + extension/background.js + extension/control.html + extension/control.js + extension/control.css + native/host.py + workflow/info.plist + workflow/scripts/dia_tabs.py + build.sh + install.sh + tests/background.test.mjs + tests/control.test.mjs + tests/test_native.py + tests/test_workflow.py + tests/markdown_cases.json +docs/plugins/dia_tabs.md +docs/specs/2026-11-01-dia-tabs-design.md +.github/workflows/dia-tabs.yml +``` + +安装到稳定用户目录,不能让 native manifest 指向临时 worktree。安装脚本解析已安装的 Python 3 绝对路径并生成 launcher;不能假设 Alfred 或 Dia 继承交互 shell 的 PATH。native manifest 的 allowed_origins 只允许本扩展 ID,不允许通配符。扩展建议提供稳定的 manifest 公钥以固定 unpacked ID;公钥不是密钥,不应提交私钥。 + +2026-11-01 在本机 Dia 1.55.1 实测确认 native host 使用 `~/Library/Application Support/Google/Chrome/NativeMessagingHosts`:注册到 `Dia/User Data/NativeMessagingHosts` 时返回 `Specified native messaging host not found`,改为 Chrome 用户目录后连接成功,Alfred 可以实时列出网页。安装器默认采用已实测目录,保留 `--native-host-dir` 覆盖。[AdGuard 官方仓库的浏览器兼容表](https://github.com/AdguardTeam/CompatibilityIssues/blob/master/mac/browsers.json)将 Dia 配置为前一种目录,与本机结果冲突;该表是 AdGuard 的适配实现,目标机器连通证据优先,不推断其他 Dia 版本的查找顺序。只保留本工具在已验证目录中的 manifest,删除测试产生的本工具冗余 manifest;不得改动已有浏览器配置或写系统目录。构建产物包含 unpacked 扩展、本机安装文件和 `.alfredworkflow`,不包含用户列表、socket、配置副本或缓存。 + +安装顺序:构建及离线验证 → 安装稳定文件/native host → 在 Dia 加载 unpacked 扩展 → 检查桥接连通 → 导入 Alfred Workflow → 集中进行实机验收。用户使用 Dia 时不进行分散 UI 探查,不重启或抢占浏览器。卸载仅删除本工具已安装文件和已确认归属的 manifest,不批量清理用户目录。 + +## 验证与完成标准 + +| 层次 | 必须覆盖 | +|---|---| +| 扩展状态测试 | active/complete 门槛;导航失效;后台保留;关闭清理;延迟响应;同 URL 双标签;SW 初始化恢复;tabs.title 不能成为内容来源 | +| 控制页测试 | 普通/Ctrl/Cmd/Shift 选择;按钮不冒泡;关闭后选择清理;输出顺序;特殊字符转义 | +| Python 协议测试 | 分片帧、UTF-8 字节长度、超限、无效 JSON、并发请求、超时、EOF 退出、陈旧 socket、错误 session | +| Alfred 测试 | `dia list` 解析和过滤;mods 参数;不拼 shell;断连空态;复制失败不能报成功 | +| 包检查 | JS 语法与 Node 测试、Python unittest、shell 语法、plutil、zip 内容和解压检查 | +| Dia 实机 | 手动重命名仍读 DOM title;已改名页继续动态改 title;active 加载完成采集;后台导航清空;两个同 URL 标签精确激活;跨窗口/最小化;关闭清理;native 重连;浏览器关闭后 Alfred 无旧结果 | +| Alfred 实机 | 输入 `dia list` 展示与搜索;回车激活已有标签;Cmd/Alt 复制;本机真实剪贴板内容;无额外新标签 | + +离线测试不能替代 Dia 实机验证。若某项因用户正在使用浏览器无法完成,交付应列出未验证项,不能声称全部可用。当前环境已确认 Dia 1.55.1、Alfred 5、Python 3 和 Node 可用;具体 Chromium 内核版本、Native Messaging 路径、标签 API 与上述行为的兼容性仍需最终核对。 + +## 落地顺序与验证命令 + +1. 扩展实现 manifest、标题缓存/事件处理、控制页及 Node 测试;本机端实现 host、Alfred、安装打包及 Python 测试。两端严格遵守上述固定协议。 +2. 合并后完成独立 CI、README/AGENTS/使用说明入口。构建产物进入本插件 dist;检查现有 release 约定,避免影响其他资产。 +3. 跑离线检查并审查生成包;安装稳定路径文件后,再集中进行 Dia 和 Alfred 实机验收。所有测试网页、截图和提交附件仅使用无敏感演示数据。 +4. 根据实机证据修正注册目录与兼容性说明,再提交 PR;不把待验证事实写成保证。 + +建议统一提供 `plugins/dia_tabs/tests/run.sh` 封装离线检查,底层至少执行以下命令(具体文件名与实现保持一致): + +```bash +node --test plugins/dia_tabs/tests/*.test.mjs +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s plugins/dia_tabs/tests -p 'test_*.py' +bash -n plugins/dia_tabs/build.sh plugins/dia_tabs/install.sh +plutil -lint plugins/dia_tabs/workflow/info.plist +plugins/dia_tabs/build.sh +git diff --check +``` + +扩展含 JS 模块时用 module 方式检查语法,避免以 CommonJS 模式检查产生伪错误。打包脚本输出实际产物路径,随后逐个执行 `unzip -t` 并检查包中没有用户状态;此插件不运行仓库根目录 Go build/test。 + +## 依据 + +- 项目现有 `AGENTS.md`、`README.MD`、`plugins/alfred_remote_upload/build.sh`、Workflow plist 与 `.github/workflows/alfred-workflow.yml`:独立插件、源码打包、离线测试与 macOS 实机验证约定。 +- [Chrome Tabs API](https://developer.chrome.com/docs/extensions/reference/api/tabs):active 是窗口内选中状态;activeTab 权限需用户操作;激活 tab 不等于聚焦 window。 +- [Chrome Scripting API](https://developer.chrome.com/docs/extensions/reference/api/scripting):运行时注入和顶层 frame 的脚本结果。 +- [Chrome Storage API](https://developer.chrome.com/docs/extensions/reference/api/storage):session storage 在浏览器重启及扩展重载后清除,可用于 service worker 状态。 +- [Chrome service worker 生命周期](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle):worker 会终止,全局变量不能持久保存;Chrome 105 起 Native Messaging 连接可保持 worker 存活。 +- [Chrome Native Messaging](https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging):stdio 帧格式、host manifest、严格 allowed_origins;浏览器之间的注册目录存在差异,Chrome 路径不能自动视为 Dia 路径。 +- [Alfred Script Filter JSON](https://www.alfredapp.com/help/workflows/inputs/script-filter/json/):items、arg、mods、valid 等交互字段。 +- [Dia 1.17.0 发布说明](https://www.diabrowser.com/changelog/mac/1-17-0):Manifest V2 扩展已禁用,新增扩展采用 MV3。 diff --git a/plugins/dia_tabs/build.sh b/plugins/dia_tabs/build.sh new file mode 100755 index 0000000..052879a --- /dev/null +++ b/plugins/dia_tabs/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "${PYTHON:-python3}" "$PLUGIN_DIR/native/build.py" "${1:-$PLUGIN_DIR/dist}" "${2:-dev}" diff --git a/plugins/dia_tabs/extension/background.js b/plugins/dia_tabs/extension/background.js new file mode 100644 index 0000000..ebc76fb --- /dev/null +++ b/plugins/dia_tabs/extension/background.js @@ -0,0 +1,72 @@ +import {createTabService} from './state.js'; + +const service = createTabService(chrome); +const hostName = 'com.mcoder2014.life_tools.dia_tabs'; +let port = null; +let bridgeState = 'connecting'; +let bridgeMessage = ''; + +function connectBridge() { + if (port) return; + bridgeState = 'connecting'; + bridgeMessage = ''; + try { + const connection = chrome.runtime.connectNative(hostName); + port = connection; + bridgeState = 'connected'; + connection.onMessage.addListener(request => { + service.handleRequest(request).then(response => { + if (port === connection) { + try { connection.postMessage(response); } catch { /* Disconnect handler schedules retry. */ } + } + }); + }); + connection.onDisconnect.addListener(() => { + const error = chrome.runtime.lastError; + if (port !== connection) return; + port = null; + bridgeState = 'disconnected'; + bridgeMessage = error?.message || '本机桥接已断开'; + }); + } catch { + port = null; + bridgeState = 'disconnected'; + bridgeMessage = '本机桥接不可用,请检查安装'; + } +} + +chrome.runtime.onMessage.addListener((request, sender, reply) => { + if (sender.id !== chrome.runtime.id) return false; + if (request?.type === 'dia-tabs-title-changed' && sender.tab && sender.frameId === 0) { + service.refreshTab(sender.tab.id); + reply({ok: true}); + return false; + } + // Content scripts can only notify. List/activation/bridge controls belong to + // the extension's own page; website input cannot choose a tab ID to activate. + if (!sender.url?.startsWith(chrome.runtime.getURL(''))) return false; + if (request?.type === 'dia-tabs-bridge-status' || request?.type === 'dia-tabs-reconnect') { + if (request.type === 'dia-tabs-reconnect') connectBridge(); + reply({state: bridgeState, message: bridgeMessage}); + return false; + } + service.handleRequest(request).then(reply); + return true; +}); +chrome.alarms.onAlarm.addListener(alarm => { + if (alarm.name === 'dia-tabs-reconnect') connectBridge(); +}); +chrome.action.onClicked.addListener(async () => { + const url = chrome.runtime.getURL('control.html'); + const pages = await chrome.tabs.query({url}); + const existing = pages.find(tab => !tab.incognito); + if (existing) { + await chrome.tabs.update(existing.id, {active: true}); + const window = await chrome.windows.get(existing.windowId); + if (window.state === 'minimized') await chrome.windows.update(existing.windowId, {state: 'normal'}); + await chrome.windows.update(existing.windowId, {focused: true}); + } else await chrome.tabs.create({url}); +}); +chrome.alarms.create('dia-tabs-reconnect', {periodInMinutes: 1}); +connectBridge(); +service.ready.catch(() => {}); diff --git a/plugins/dia_tabs/extension/control-core.js b/plugins/dia_tabs/extension/control-core.js new file mode 100644 index 0000000..f1ca02e --- /dev/null +++ b/plugins/dia_tabs/extension/control-core.js @@ -0,0 +1,30 @@ +export function markdownLink(title, url) { + const label = (title || url).replace(/\r\n|\r|\n/g, ' ').replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]'); + const destination = url.replace(/[\x00-\x20\x7f()<>\\]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`); + return `[${label}](${destination})`; +} + +export function updateSelection(selected, anchor, tabId, visibleIds, event) { + const next = new Set(selected); + if (event.shiftKey && visibleIds.includes(anchor)) { + if (!event.ctrlKey && !event.metaKey) next.clear(); + const first = visibleIds.indexOf(anchor), last = visibleIds.indexOf(tabId); + for (const id of visibleIds.slice(Math.min(first, last), Math.max(first, last) + 1)) next.add(id); + } else if (event.ctrlKey || event.metaKey) { + if (next.has(tabId)) next.delete(tabId); else next.add(tabId); + anchor = tabId; + } else { + next.clear(); next.add(tabId); anchor = tabId; + } + return {selected: next, anchor}; +} + +export function retainSelection(selected, tabs) { + const live = new Set(tabs.map(tab => tab.tabId)); + return new Set([...selected].filter(id => live.has(id))); +} + +export function selectedOutput(tabs, selected, format) { + return tabs.filter(tab => selected.has(tab.tabId)).map(tab => format === 'markdown' + ? markdownLink(tab.titleState === 'captured' ? tab.title : '', tab.url) : tab.url).join('\n'); +} diff --git a/plugins/dia_tabs/extension/control.css b/plugins/dia_tabs/extension/control.css new file mode 100644 index 0000000..42fffe2 --- /dev/null +++ b/plugins/dia_tabs/extension/control.css @@ -0,0 +1,39 @@ +:root { color-scheme: light dark; --surface:#fff; --background:#f5f6f8; --ink:#202830; --muted:#69737e; --border:#dce2e8; --accent:#235db5; --selected:#edf4ff; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } +* { box-sizing:border-box; } +body { margin:0; background:var(--background); color:var(--ink); font-size:14px; } +main { max-width:1440px; margin:40px auto; padding:0 32px; } +header,.bridge,.toolbar,.actions,.table-meta { display:flex; align-items:center; gap:12px; } +header,.toolbar,.table-meta { justify-content:space-between; } +h1 { font-size:28px; letter-spacing:-.6px; margin:6px 0 0; font-weight:650; } +.eyebrow { font-size:11px; color:var(--muted); letter-spacing:1.5px; margin:0; } +.intro { color:var(--muted); margin:14px 0 22px; } +button { font:inherit; cursor:pointer; border:1px solid var(--border); border-radius:7px; padding:8px 13px; background:var(--surface); color:var(--ink); } +button:hover { border-color:var(--accent); } +button:disabled { opacity:.45; cursor:default; } +button:focus-visible,a:focus-visible,input:focus-visible { outline:2px solid var(--accent); outline-offset:3px; } +.bridge { border:1px solid var(--border); background:var(--surface); border-radius:10px; padding:12px 16px; flex-wrap:wrap; } +.bridge button { padding:5px 10px; } +.muted,.footnote,.table-meta { color:var(--muted); font-size:12px; } +.toolbar { margin:24px 0 14px; align-items:flex-end; flex-wrap:wrap; } +.search { display:flex; flex-direction:column; gap:7px; font-size:12px; color:var(--muted); min-width:250px; width:40%; } +input[type=search] { width:100%; font:inherit; font-size:14px; border:1px solid var(--border); border-radius:7px; padding:10px 12px; color:var(--ink); background:var(--surface); } +input[type=checkbox] { accent-color:var(--accent); cursor:pointer; width:16px; height:16px; } +.table-meta { margin:16px 0 9px; } +.table-wrap { border:1px solid var(--border); border-radius:10px; overflow:auto; background:var(--surface); } +table { width:100%; border-collapse:collapse; table-layout:fixed; min-width:750px; } +th { font-size:12px; color:var(--muted); font-weight:500; text-align:left; padding:12px; border-bottom:1px solid var(--border); } +td { padding:13px 12px; border-bottom:1px solid var(--border); vertical-align:middle; overflow-wrap:anywhere; } +tr:last-child td { border-bottom:none; } +tbody tr { cursor:pointer; } +tbody tr:hover { background:var(--background); } +tbody tr.selected { background:var(--selected); } +.select-col { width:46px; }.state-col { width:150px; }.copy-col { width:100px; } +.title-button { border:0; padding:0; text-align:left; border-radius:2px; background:transparent; line-height:1.5; font-weight:550; } +.url { color:var(--accent); text-decoration:none; font-size:12px; line-height:1.5; } +.url:hover,.title-button:hover { text-decoration:underline; } +.state { color:var(--muted); font-size:12px; line-height:1.7; } +.copy-button { padding:5px 9px; font-size:12px; } +#empty { padding:36px; text-align:center; color:var(--muted); } +#notice { min-height:24px; color:var(--accent); } +@media (prefers-color-scheme:dark) { :root { --surface:#20242a; --background:#15191e; --ink:#e7ecf1; --muted:#a2adba; --border:#38414b; --accent:#88b5fc; --selected:#23364d; } } +@media (max-width:700px) { main { margin:24px auto; padding:0 16px; }.search { width:100%; }.table-meta { align-items:flex-start; flex-direction:column; }.actions { flex-wrap:wrap; } } diff --git a/plugins/dia_tabs/extension/control.html b/plugins/dia_tabs/extension/control.html new file mode 100644 index 0000000..eaaa567 --- /dev/null +++ b/plugins/dia_tabs/extension/control.html @@ -0,0 +1,38 @@ + + + + + + Dia 网页标题与标签 + + + +
+
+

LIFE TOOLS · DIA

网页标题与标签

+ +
+

记录页面原始标题。点击标题或网址,切换到已有标签。

+
+ 正在检查本机桥接… + + 桥接用于 Alfred;本页可独立使用。 +
+
+ +
+
+
正在读取…单击行选择 · ⌘/Ctrl 切换 · Shift 连选
+
+ + + +
网页原始标题网址状态 / 窗口复制
+ +
+

未访问的后台页面显示“待访问”;切换到该页面且加载完成后记录标题。隐私窗口不在列表内。

+

+
+ + + diff --git a/plugins/dia_tabs/extension/control.js b/plugins/dia_tabs/extension/control.js new file mode 100644 index 0000000..e398c5a --- /dev/null +++ b/plugins/dia_tabs/extension/control.js @@ -0,0 +1,124 @@ +import {updateSelection, retainSelection, selectedOutput} from './control-core.js'; + +const byId = id => document.getElementById(id); +let tabs = [], selected = new Set(), anchor = null, sessionId = '', loading = false; +let refreshQueued = false; + +function visibleTabs() { + const query = byId('search').value.trim().toLocaleLowerCase(); + return tabs.filter(tab => `${tab.title}\n${tab.url}`.toLocaleLowerCase().includes(query)); +} + +async function copy(text) { + try { + await navigator.clipboard.writeText(text); + byId('notice').textContent = '已复制到剪贴板'; + } catch { byId('notice').textContent = '复制失败,请检查剪贴板权限后重试。'; } +} + +async function activate(tabId) { + const response = await chrome.runtime.sendMessage({v:1, id:crypto.randomUUID(), op:'activate', sessionId, tabId}).catch(() => null); + if (!response?.ok) { + byId('notice').textContent = response?.message || '无法切换标签,请刷新列表。'; + if (response?.error === 'TAB_CLOSED' || response?.error === 'STALE_SESSION') await refresh(); + } +} + +function render() { + const shown = visibleTabs(); + const visibleIds = shown.map(tab => tab.tabId); + const body = byId('tabs'); + body.replaceChildren(); + for (const tab of shown) { + const row = document.createElement('tr'); + row.classList.toggle('selected', selected.has(tab.tabId)); + row.setAttribute('aria-selected', String(selected.has(tab.tabId))); + row.addEventListener('click', event => { + const result = updateSelection(selected, anchor, tab.tabId, visibleIds, event); + selected = result.selected; anchor = result.anchor; render(); + }); + const checkCell = document.createElement('td'); + const check = document.createElement('input'); + check.type = 'checkbox'; check.checked = selected.has(tab.tabId); check.setAttribute('aria-label', `选择 ${tab.title || tab.url}`); + check.addEventListener('click', event => { + event.stopPropagation(); + const result = updateSelection(selected, anchor, tab.tabId, visibleIds, {...event, ctrlKey:true, shiftKey:event.shiftKey}); + selected = result.selected; anchor = result.anchor; render(); + }); + checkCell.append(check); + const titleCell = document.createElement('td'); + const title = document.createElement('button'); + title.className = 'title-button'; title.type = 'button'; + title.textContent = tab.titleState === 'captured' ? (tab.title || '无标题') : tab.titleState === 'pending' ? '待访问' : '无法读取页面标题'; + const navigate = event => { + event.preventDefault(); + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; + event.stopPropagation(); activate(tab.tabId); + }; + title.addEventListener('click', navigate); titleCell.append(title); + const urlCell = document.createElement('td'); + const link = document.createElement('a'); + link.className = 'url'; link.href = tab.url; link.textContent = tab.url; + link.addEventListener('click', navigate); + link.addEventListener('auxclick', event => { event.preventDefault(); event.stopPropagation(); }); + urlCell.append(link); + const stateCell = document.createElement('td'); stateCell.className = 'state'; + stateCell.textContent = `${tab.active ? '当前标签 · ' : ''}${tab.titleState === 'captured' ? '已记录' : tab.titleState === 'pending' ? '待访问' : '无法读取'} · 窗口 ${tab.windowId}`; + const copyCell = document.createElement('td'); + const copyButton = document.createElement('button'); copyButton.type = 'button'; copyButton.className = 'copy-button'; copyButton.textContent = '复制 URL'; + copyButton.addEventListener('click', event => { event.stopPropagation(); copy(tab.url); }); copyCell.append(copyButton); + row.append(checkCell,titleCell,urlCell,stateCell,copyCell); body.append(row); + } + const selectedVisible = shown.filter(tab => selected.has(tab.tabId)).length; + byId('count').textContent = `${shown.length} 个网页 / 共 ${tabs.length} 个 · 已选择 ${selectedVisible} 个可见标签`; + byId('copy-urls').disabled = selectedVisible === 0; byId('copy-markdown').disabled = selectedVisible === 0; + byId('select-all').checked = shown.length > 0 && selectedVisible === shown.length; + byId('select-all').indeterminate = selectedVisible > 0 && selectedVisible < shown.length; + byId('empty').hidden = shown.length !== 0; +} + +async function bridge(reconnect = false) { + const result = await chrome.runtime.sendMessage({type:reconnect ? 'dia-tabs-reconnect' : 'dia-tabs-bridge-status'}).catch(() => null); + byId('bridge-status').textContent = result?.state === 'connected' ? '本机桥接已连接' : result?.state === 'connecting' ? '正在连接本机桥接…' : `本机桥接未连接${result?.message ? `:${result.message}` : ''}`; +} + +async function refresh() { + if (loading) { refreshQueued = true; return; } + loading = true; + byId('refresh').disabled = true; + try { + const response = await chrome.runtime.sendMessage({v:1,id:crypto.randomUUID(),op:'list'}); + if (!response?.ok) throw Error(response?.message || '列表读取失败'); + const changed = sessionId !== response.sessionId || JSON.stringify(tabs) !== JSON.stringify(response.tabs); + if (sessionId && sessionId !== response.sessionId) {selected.clear(); anchor = null;} + sessionId = response.sessionId; tabs = response.tabs; + selected = retainSelection(selected, tabs); + if (!tabs.some(tab => tab.tabId === anchor)) anchor = null; + if (changed) render(); + } catch { byId('notice').textContent = '无法读取标签列表,请重新加载扩展后刷新本页。'; } + finally { + loading = false; byId('refresh').disabled = false; + if (refreshQueued) { refreshQueued = false; refresh(); } + } + await bridge(); +} + +byId('search').addEventListener('input', render); +byId('refresh').addEventListener('click', refresh); +byId('reconnect').addEventListener('click', async () => { await bridge(true); setTimeout(() => bridge(), 500); }); +byId('copy-urls').addEventListener('click', () => copy(selectedOutput(visibleTabs(), selected, 'url'))); +byId('copy-markdown').addEventListener('click', () => copy(selectedOutput(visibleTabs(), selected, 'markdown'))); +byId('select-all').addEventListener('change', event => { + for (const tab of visibleTabs()) { if (event.target.checked) selected.add(tab.tabId); else selected.delete(tab.tabId); } + render(); +}); +chrome.tabs.onCreated.addListener(() => refresh()); +chrome.tabs.onRemoved.addListener(() => refresh()); +chrome.tabs.onUpdated.addListener(() => refresh()); +chrome.tabs.onActivated.addListener(() => refresh()); +chrome.storage.onChanged.addListener((changes, area) => { + const change = changes.diaTabsRecords; + if (area === 'session' && change && JSON.stringify(change.oldValue) !== JSON.stringify(change.newValue)) refresh(); +}); +window.addEventListener('focus', refresh); +refresh(); diff --git a/plugins/dia_tabs/extension/manifest.json b/plugins/dia_tabs/extension/manifest.json new file mode 100644 index 0000000..176d5ce --- /dev/null +++ b/plugins/dia_tabs/extension/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 3, + "name": "Dia 网页标题与标签", + "version": "1.0.0", + "description": "保存真实网页标题,复制链接并准确切换已有 Dia 标签。", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt3Pu6Q6EcBBMliL6aT081/77X2xtlPUVbY58jCLM/BMycifEssxFe86XusGB6/gIbmcpBwKYSsMzDp7DgOTRtJa9Cwih15+HC4vCtYpVuxcAy2ZbmBwvRHhdAC2Q5X3zPysLqSSrnQ6QmdO/cpsiGU72Pg7MlqK5+8FHyD/sYeq/bbRXOuy5T7h9P7khJAMFq5JqPJ15o4eNIHPav2W7RzwNqerIyOm0/yZ37Q/TGdjZPQ97LRGbM5VcEDLZ53j4GNYRJJ7xbP227SAywnA1s+/7XMAAjz2eVvHifzDlWOvK74QoEWv8BfvOStZN83L2MqpAADXvcg9ZWt9R1b53EwIDAQAB", + "incognito": "not_allowed", + "permissions": [ + "tabs", + "scripting", + "storage", + "nativeMessaging", + "alarms", + "clipboardWrite" + ], + "host_permissions": [ + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, + "action": { + "default_title": "打开网页标题列表" + }, + "options_page": "control.html" +} diff --git a/plugins/dia_tabs/extension/state.js b/plugins/dia_tabs/extension/state.js new file mode 100644 index 0000000..768b3ce --- /dev/null +++ b/plugins/dia_tabs/extension/state.js @@ -0,0 +1,142 @@ +const isWeb = tab => !tab.incognito && /^https?:\/\//i.test(tab.url || ''); + +// Only this injected function reads page content. The observer sends a signal; +// the worker checks active/complete again before each subsequent title read. +export function readPageTitle() { + const key = '__lifeToolsDiaTitleObserverV1'; + if (!globalThis[key] && document.head) { + let timer; + const observer = new MutationObserver(mutations => { + const titleChanged = mutations.some(mutation => mutation.target.nodeName === 'TITLE' + || mutation.target.parentNode?.nodeName === 'TITLE' + || [...mutation.addedNodes, ...mutation.removedNodes].some(node => node.nodeName === 'TITLE')); + if (!titleChanged || timer) return; + timer = setTimeout(() => { + timer = null; + chrome.runtime.sendMessage({type: 'dia-tabs-title-changed'}).catch(() => {}); + }, 150); + }); + observer.observe(document.head, {subtree: true, childList: true, characterData: true}); + globalThis[key] = observer; + } + return {title: document.title, url: location.href, readyState: document.readyState}; +} + +// Session state stays in one worker. Navigation invalidation is synchronous; +// every asynchronous result is checked against both navigation and sample IDs. +export function createTabService(api) { + const records = new Map(), versions = new Map(), samples = new Map(), tasks = new Set(), initialRemovals = new Set(); + let sessionId, initialized = false, writeTail = Promise.resolve(); + const persist = () => { + writeTail = writeTail.catch(() => {}).then(() => api.storage.session.set({ + diaTabsSession: sessionId, diaTabsRecords: Object.fromEntries(records), + })); + writeTail.catch(() => {}); + }; + const track = promise => { + tasks.add(promise); + promise.catch(() => {}).finally(() => tasks.delete(promise)); + return promise; + }; + const invalidate = (id, url) => { + versions.set(id, (versions.get(id) || 0) + 1); + samples.set(id, (samples.get(id) || 0) + 1); + if (url && /^https?:\/\//i.test(url)) records.set(id, {url, title: '', titleState: 'pending'}); + else records.delete(id); + if (sessionId) persist(); + }; + const capture = async tab => { + if (!isWeb(tab) || !tab.active || tab.status !== 'complete') return; + const version = versions.get(tab.id) || 0, sample = (samples.get(tab.id) || 0) + 1; + samples.set(tab.id, sample); + let page; + try { + const results = await api.scripting.executeScript({target: {tabId: tab.id, frameIds: [0]}, func: readPageTitle}); + page = results.find(result => result.frameId === 0)?.result; + } catch { /* Restricted pages remain visible with an explicit unavailable state. */ } + let current; + try { current = await api.tabs.get(tab.id); } catch { return; } + if (!isWeb(current) || !current.active || current.status !== 'complete' || current.url !== tab.url + || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return; + if (page && (page.url !== tab.url || page.readyState !== 'complete')) return; + records.set(tab.id, {url: tab.url, title: typeof page?.title === 'string' ? page.title : '', + titleState: typeof page?.title === 'string' ? 'captured' : 'unavailable'}); + persist(); + }; + const ready = (async () => { + const saved = await api.storage.session.get(['diaTabsSession', 'diaTabsRecords']); + sessionId = typeof saved.diaTabsSession === 'string' && saved.diaTabsSession ? saved.diaTabsSession : crypto.randomUUID(); + for (const [key, record] of Object.entries(saved.diaTabsRecords || {})) { + if (!versions.has(Number(key)) && record && ['captured','pending','unavailable'].includes(record.titleState) + && typeof record.url === 'string' && typeof record.title === 'string') records.set(Number(key), record); + } + const tabs = (await api.tabs.query({})).filter(tab => isWeb(tab) && !initialRemovals.has(tab.id)); + const live = new Set(tabs.map(tab => tab.id)); + for (const id of records.keys()) if (!live.has(id)) records.delete(id); + for (const tab of tabs) { + if (records.get(tab.id)?.url !== tab.url) records.set(tab.id, {url: tab.url, title: '', titleState: 'pending'}); + } + initialized = true; + initialRemovals.clear(); + persist(); + await Promise.all(tabs.map(capture)); + })(); + const refreshTab = id => track(ready.then(async () => { + try { const tab = await api.tabs.get(id); await capture(tab); } catch { /* Tab closed. */ } + })); + api.tabs.onUpdated.addListener((id, change, tab) => { + if (change.status === 'loading' || Object.hasOwn(change, 'url')) invalidate(id, tab.incognito ? undefined : change.url || tab.url); + if (change.status === 'complete' || Object.hasOwn(change, 'title') || Object.hasOwn(change, 'url')) refreshTab(id); + }); + api.tabs.onActivated.addListener(({tabId}) => { refreshTab(tabId); }); + api.tabs.onCreated.addListener(tab => { refreshTab(tab.id); }); + api.tabs.onRemoved.addListener(id => { if (!initialized) initialRemovals.add(id); invalidate(id); }); + api.tabs.onReplaced.addListener((added, removed) => { + if (!initialized) initialRemovals.add(removed); + invalidate(removed); refreshTab(added); + }); + + const list = async () => { + await ready; + const tabs = (await api.tabs.query({})).filter(isWeb).sort((a,b) => a.windowId-b.windowId || a.index-b.index); + const live = new Set(tabs.map(tab => tab.id)); + for (const id of records.keys()) if (!live.has(id)) records.delete(id); + await Promise.all(tabs.filter(tab => !records.has(tab.id) || records.get(tab.id).url !== tab.url || records.get(tab.id).titleState === 'pending').map(capture)); + persist(); + return tabs.map(tab => { + const record = records.get(tab.id); + const valid = record?.url === tab.url; + return {tabId: tab.id, windowId: tab.windowId, index: tab.index, url: tab.url, + title: valid ? record.title : '', titleState: valid ? record.titleState : 'pending', active: tab.active}; + }); + }; + const handleRequest = async request => { + const response = {v: 1, id: typeof request?.id === 'string' ? request.id : '', ok: false}; + const fail = (error, message) => ({...response, error, message}); + if (request?.v !== 1 || typeof request.id !== 'string' || !request.id || !['list','activate'].includes(request.op)) + return fail('BAD_REQUEST', '请求格式无效'); + try { + await ready; + if (request.op === 'list') return {...response, ok: true, sessionId, tabs: await list()}; + if (typeof request.sessionId !== 'string' || !Number.isInteger(request.tabId) || request.tabId < 0) + return fail('BAD_REQUEST', '激活参数无效'); + if (request.sessionId !== sessionId) return fail('STALE_SESSION', '浏览器会话已变化,请刷新列表'); + let tab; + try { tab = await api.tabs.get(request.tabId); } catch { return fail('TAB_CLOSED', '标签已关闭'); } + if (!isWeb(tab)) return fail('TAB_CLOSED', '标签已关闭或不在支持范围'); + try { tab = await api.tabs.update(request.tabId, {active: true}); } catch { + try { await api.tabs.get(request.tabId); } catch { return fail('TAB_CLOSED', '标签已关闭'); } + return fail('INTERNAL_ERROR', '无法激活标签'); + } + const window = await api.windows.get(tab.windowId); + if (window.state === 'minimized') await api.windows.update(tab.windowId, {state: 'normal'}); + await api.windows.update(tab.windowId, {focused: true}); + return {...response, ok: true}; + } catch { return fail('INTERNAL_ERROR', '浏览器操作失败,请刷新后重试'); } + }; + return {ready, list, handleRequest, refreshTab, get sessionId() {return sessionId;}, async idle() { + await ready; + while (tasks.size) await Promise.allSettled([...tasks]); + await writeTail; + }}; +} diff --git a/plugins/dia_tabs/install.sh b/plugins/dia_tabs/install.sh new file mode 100755 index 0000000..f8e8a8f --- /dev/null +++ b/plugins/dia_tabs/install.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail +PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)" +python_bin="${PYTHON:-python3}" +exec "$python_bin" "$PLUGIN_DIR/native/install.py" "$@" diff --git a/plugins/dia_tabs/native/build.py b/plugins/dia_tabs/native/build.py new file mode 100644 index 0000000..4187df3 --- /dev/null +++ b/plugins/dia_tabs/native/build.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Create clean extension, Alfred and portable source archives.""" + +import argparse +from pathlib import Path +import re +import zipfile + +ROOT = Path(__file__).resolve().parents[1] + + +def add_tree(archive, source, prefix=""): + for path in sorted(source.rglob("*")): + relative = path.relative_to(source) + if (not path.is_file() or path.is_symlink() or any(part.startswith(".") for part in relative.parts) + or "__pycache__" in relative.parts or path.suffix in (".pyc", ".sock") + or path.name in ("prefs.plist", "install-state.json", "python.path")): + continue + archive.write(path, str(Path(prefix) / relative)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", nargs="?", type=Path, default=ROOT / "dist") + parser.add_argument("version", nargs="?", default="dev") + args = parser.parse_args() + if not re.fullmatch(r"[A-Za-z0-9._-]+", args.version): + parser.error("version can contain only letters, digits, dots, underscores and hyphens") + args.output.mkdir(parents=True, exist_ok=True) + stem = "life_tools_dia_tabs_" + artifacts = [("extension_" + args.version + ".zip", ROOT / "extension"), + (args.version + ".alfredworkflow", ROOT / "workflow")] + for name, source in artifacts: + path = args.output / (stem + name) + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + add_tree(archive, source) + print(path.resolve()) + path = args.output / (stem + "source_" + args.version + ".zip") + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + prefix = stem + args.version + for component in ("extension", "native", "workflow"): + add_tree(archive, ROOT / component, prefix + "/" + component) + for name in ("build.sh", "install.sh"): + archive.write(ROOT / name, prefix + "/" + name) + readme = ROOT.parents[1] / "docs/plugins/dia_tabs.md" + if not readme.exists(): + readme = ROOT / "README.md" + if readme.exists(): + archive.write(readme, prefix + "/README.md") + print(path.resolve()) + + +if __name__ == "__main__": + main() diff --git a/plugins/dia_tabs/native/host.py b/plugins/dia_tabs/native/host.py new file mode 100644 index 0000000..6bbf7f2 --- /dev/null +++ b/plugins/dia_tabs/native/host.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Forward one live browser Native Messaging connection to private Unix clients.""" + +import json +import os +from pathlib import Path +import select +import socket +import stat +import struct +import sys +import threading +import time +import uuid + +MAX_MESSAGE = 1024 * 1024 +MAX_CLIENTS = 32 +DEFAULT_RUN_DIR = Path.home() / ".local/share/life-tools-dia-tabs/run" + + +def read_exact(stream, count): + data = bytearray() + while len(data) < count: + chunk = stream.read(count - len(data)) + if not chunk: + if not data: + return None + raise ValueError("truncated native message") + data.extend(chunk) + return bytes(data) + + +def read_native(stream): + header = read_exact(stream, 4) + if header is None: + return None + length = struct.unpack("=I", header)[0] + if not 0 < length <= MAX_MESSAGE: + raise ValueError("invalid native message length") + payload = read_exact(stream, length) + if payload is None: + raise ValueError("truncated native message") + return json.loads(payload.decode("utf-8")) + + +def error_response(request_id, code, message): + return {"v": 1, "id": request_id, "ok": False, "error": code, "message": message} + + +def validate_request(value): + if not isinstance(value, dict) or type(value.get("v")) is not int or value["v"] != 1: + return False + if not isinstance(value.get("id"), str) or not 0 < len(value["id"]) <= 128: + return False + if value.get("op") == "list": + return set(value) == {"v", "id", "op"} + return (value.get("op") == "activate" + and set(value) == {"v", "id", "op", "sessionId", "tabId"} + and isinstance(value.get("sessionId"), str) and 0 < len(value["sessionId"]) <= 128 + and type(value.get("tabId")) is int and value["tabId"] >= 0) + + +class Bridge: + def __init__(self, run_dir, timeout=2.0): + self.run_dir = Path(run_dir) + self.path = self.run_dir / (uuid.uuid4().hex[:12] + ".sock") + self.timeout = timeout + self.pending = {} + self.pending_lock = threading.Lock() + self.write_lock = threading.Lock() + self.stopped = threading.Event() + self.slots = threading.BoundedSemaphore(MAX_CLIENTS) + self.workers = [] + + def stop(self): + self.stopped.set() + with self.pending_lock: + for pending in self.pending.values(): + pending["event"].set() + + def read_browser(self): + try: + while not self.stopped.is_set(): + value = read_native(sys.stdin.buffer) + if value is None: + break + if (not isinstance(value, dict) or value.get("v") != 1 + or not isinstance(value.get("id"), str) or type(value.get("ok")) is not bool): + raise ValueError("invalid native response") + with self.pending_lock: + pending = self.pending.get(value.get("id")) + if pending is not None: + pending["response"] = value + pending["event"].set() + except (OSError, ValueError, UnicodeError): + print("Dia Tabs: invalid or disconnected native stream", file=sys.stderr) + finally: + self.stop() + + def forward(self, client, request): + internal_id = uuid.uuid4().hex + pending = {"event": threading.Event(), "response": None} + with self.pending_lock: + self.pending[internal_id] = pending + try: + payload = json.dumps(dict(request, id=internal_id), ensure_ascii=False).encode("utf-8") + with self.write_lock: + if self.stopped.is_set(): + return error_response(request["id"], "BRIDGE_UNAVAILABLE", "浏览器连接已断开") + sys.stdout.buffer.write(struct.pack("=I", len(payload)) + payload) + sys.stdout.buffer.flush() + deadline = time.monotonic() + self.timeout + while not pending["event"].wait(min(0.05, max(0, deadline - time.monotonic()))): + if time.monotonic() >= deadline: + return error_response(request["id"], "TIMEOUT", "浏览器响应超时") + if select.select([client], [], [], 0)[0] and not client.recv(1, socket.MSG_PEEK): + return None + if pending["response"] is None: + return error_response(request["id"], "BRIDGE_UNAVAILABLE", "浏览器连接已断开") + return dict(pending["response"], id=request["id"]) + except (OSError, BrokenPipeError): + self.stop() + return error_response(request["id"], "BRIDGE_UNAVAILABLE", "浏览器连接已断开") + finally: + with self.pending_lock: + self.pending.pop(internal_id, None) + + def handle_client(self, client): + request_id = "" + try: + client.settimeout(self.timeout) + with client.makefile("rb") as stream: + line = stream.readline(MAX_MESSAGE + 1) + if len(line) > MAX_MESSAGE or not line.endswith(b"\n"): + raise ValueError("invalid client message length") + request = json.loads(line.decode("utf-8")) + if isinstance(request, dict) and isinstance(request.get("id"), str): + request_id = request["id"][:128] + if not validate_request(request): + raise ValueError("invalid request") + response = self.forward(client, request) + if response is not None: + client.sendall(json.dumps(response, ensure_ascii=False).encode("utf-8") + b"\n") + except (ValueError, UnicodeError, socket.timeout): + try: + client.sendall(json.dumps(error_response(request_id, "BAD_REQUEST", "无效的桥接请求")).encode() + b"\n") + except OSError: + pass + except OSError: + pass + finally: + client.close() + self.slots.release() + + def run(self): + if len(os.fsencode(self.path)) >= 104: + raise ValueError("Unix socket path must be shorter than 104 bytes; set DIA_TABS_RUN_DIR") + self.run_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + info = self.run_dir.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid(): + raise ValueError("run directory must be a real directory owned by the current user") + self.run_dir.chmod(0o700) + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server: + server.bind(str(self.path)) + self.path.chmod(0o600) + server.listen(MAX_CLIENTS) + server.settimeout(0.1) + threading.Thread(target=self.read_browser, daemon=True).start() + while not self.stopped.is_set(): + try: + client, _ = server.accept() + except socket.timeout: + continue + if not self.slots.acquire(blocking=False): + client.close() + continue + self.workers = [worker for worker in self.workers if worker.is_alive()] + worker = threading.Thread(target=self.handle_client, args=(client,), daemon=True) + self.workers.append(worker) + worker.start() + finally: + self.stop() + for worker in self.workers: + worker.join(timeout=self.timeout + 0.2) + self.path.unlink(missing_ok=True) + + +def main(): + os.umask(0o077) + try: + timeout = float(os.environ.get("DIA_TABS_TIMEOUT", "2")) + if not 0.05 <= timeout <= 30: + raise ValueError("DIA_TABS_TIMEOUT must be between 0.05 and 30 seconds") + Bridge(os.environ.get("DIA_TABS_RUN_DIR", DEFAULT_RUN_DIR), timeout).run() + return 0 + except (OSError, ValueError) as error: + print("Dia Tabs: " + str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/dia_tabs/native/install.py b/plugins/dia_tabs/native/install.py new file mode 100644 index 0000000..97ac86a --- /dev/null +++ b/plugins/dia_tabs/native/install.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Install Dia Tabs in one stable user directory without sudo or UI automation.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import plistlib +import shlex +import shutil +import subprocess +import sys + +HOST_NAME = "com.mcoder2014.life_tools.dia_tabs" +BUNDLE_ID = "com.mcoder2014.life-tools.dia-tabs" +SOURCE = Path(__file__).resolve().parents[1] +DEFAULT_PREFIX = Path.home() / ".local/share/life-tools-dia-tabs" +DEFAULT_HOST_DIR = Path.home() / "Library/Application Support/Google/Chrome/NativeMessagingHosts" + + +def extension_id(manifest): + digest = hashlib.sha256(base64.b64decode(manifest["key"], validate=True)).hexdigest()[:32] + return "".join(chr(ord("a") + int(char, 16)) for char in digest) + + +def write_json(path, value): + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + path.chmod(0o600) + + +def copy_source(source, target): + if target.is_symlink(): + raise ValueError("安装目标不能是符号链接: " + str(target)) + shutil.copytree(source, target, dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", "prefs.plist")) + + +def launcher(path, python, script, run_dir): + path.write_text("#!/bin/sh\nexport DIA_TABS_RUN_DIR={}\nexec {} {} \"$@\"\n".format( + shlex.quote(str(run_dir)), shlex.quote(str(python)), shlex.quote(str(script))), encoding="utf-8") + path.chmod(0o700) + + +def install(args): + prefix = args.prefix.expanduser().resolve() + if prefix == SOURCE or SOURCE in prefix.parents: + raise ValueError("请使用工作树之外的稳定安装目录") + run_dir = prefix / "run" + if len(os.fsencode(run_dir / ("0" * 12 + ".sock"))) >= 104: + raise ValueError("安装目录过长,Unix socket 路径必须小于 104 字节") + python_path = shutil.which(str(args.python)) + if not python_path: + raise ValueError("未找到 Python 3: " + str(args.python)) + python_path = Path(python_path).resolve() + subprocess.run([str(python_path), "-c", "import sys; assert sys.version_info >= (3, 9)"], + check=True, timeout=10) + manifest = json.loads((SOURCE / "extension/manifest.json").read_text(encoding="utf-8")) + ext_id = extension_id(manifest) + host_dir = args.native_host_dir.expanduser().resolve() + host_path = host_dir / (HOST_NAME + ".json") + if host_path.exists(): + previous = json.loads(host_path.read_text(encoding="utf-8")) + if previous.get("name") != HOST_NAME: + raise ValueError("拒绝覆盖不属于本工具的 native manifest") + workflow_target = None + if args.workflow_dir: + workflow_target = args.workflow_dir.expanduser().resolve() / "user.workflow.life-tools-dia-tabs" + if (workflow_target / "info.plist").exists(): + with (workflow_target / "info.plist").open("rb") as stream: + if plistlib.load(stream).get("bundleid") != BUNDLE_ID: + raise ValueError("拒绝覆盖其他 Alfred Workflow") + prefix.mkdir(parents=True, exist_ok=True, mode=0o700) + prefix.chmod(0o700) + for component in ("extension", "native", "workflow"): + copy_source(SOURCE / component, prefix / component) + run_dir.mkdir(mode=0o700, exist_ok=True) + run_dir.chmod(0o700) + launcher(prefix / "native-host", python_path, prefix / "native/host.py", run_dir) + launcher(prefix / "dia-tabs", python_path, prefix / "workflow/scripts/dia_tabs.py", run_dir) + host_dir.mkdir(parents=True, exist_ok=True) + write_json(host_path, {"name": HOST_NAME, "description": "Dia Tabs live-tab bridge", + "path": str(prefix / "native-host"), "type": "stdio", + "allowed_origins": ["chrome-extension://" + ext_id + "/"]}) + if workflow_target: + copy_source(SOURCE / "workflow", workflow_target) + with (workflow_target / "info.plist").open("rb") as stream: + workflow = plistlib.load(stream) + workflow.setdefault("variables", {})["DIA_TABS_HOME"] = str(prefix) + with (workflow_target / "info.plist").open("wb") as stream: + plistlib.dump(workflow, stream, sort_keys=False) + for script in (workflow_target / "scripts").glob("*.sh"): + script.chmod(0o755) + write_json(prefix / "install-state.json", {"tool": HOST_NAME, "prefix": str(prefix), + "nativeManifest": str(host_path), "workflow": str(workflow_target) if workflow_target else None, + "extensionId": ext_id, "python": str(python_path)}) + print("安装目录: " + str(prefix)) + print("Dia 解压扩展目录: " + str(prefix / "extension")) + print("Native manifest: " + str(host_path)) + print("扩展 ID: " + ext_id) + print("Python: " + str(python_path)) + if workflow_target: + print("Alfred Workflow: " + str(workflow_target)) + else: + print("请在稳定文件安装后导入构建生成的 Dia Tabs .alfredworkflow") + print("默认 Native 注册目录已在 Dia 1.55.1 验证;其他版本可用 --native-host-dir 覆盖。") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--prefix", type=Path, default=DEFAULT_PREFIX, help="稳定用户安装目录") + parser.add_argument("--python", default=sys.executable, help="Python 3.9+ 可执行文件") + parser.add_argument("--native-host-dir", type=Path, default=DEFAULT_HOST_DIR, + help="单一 native host 注册目录;默认 Google/Chrome/NativeMessagingHosts") + parser.add_argument("--workflow-dir", type=Path, + help="Alfred workflows 父目录;只更新本工具自己的子目录") + args = parser.parse_args() + try: + install(args) + return 0 + except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error: + print("安装失败: " + str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/dia_tabs/tests/background.test.mjs b/plugins/dia_tabs/tests/background.test.mjs new file mode 100644 index 0000000..aa78b9e --- /dev/null +++ b/plugins/dia_tabs/tests/background.test.mjs @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +const source = await readFile(new URL('../extension/state.js', import.meta.url), 'utf8').catch(() => ''); +const {createTabService} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + +function event() { + const listeners = []; + return {addListener(fn) {listeners.push(fn);}, emit(...args) {for (const fn of listeners) fn(...args);}}; +} +function fake(tabs, saved = {}) { + const current = new Map(tabs.map(tab => [tab.id, {...tab}])); + const calls = []; + const storage = {...saved}; + const api = { + tabs: { + onUpdated:event(),onActivated:event(),onRemoved:event(),onReplaced:event(),onCreated:event(), + async query() {return [...current.values()].map(tab => ({...tab}));}, + async get(id) {if (!current.has(id)) throw Error('closed'); return {...current.get(id)};}, + async update(id, changes) {const tab=await this.get(id); current.set(id,{...tab,...changes}); calls.push(['tab',id,changes]); return current.get(id);}, + }, + scripting: {async executeScript({target}) {calls.push(['script',target.tabId]);return [{frameId:0,result:{title:`DOM ${target.tabId}`,url:current.get(target.tabId)?.url,readyState:'complete'}}];}}, + storage:{session:{async get() {return structuredClone(storage);},async set(value) {Object.assign(storage,structuredClone(value));}}}, + windows:{async get(id) {return {id,state:'minimized'};},async update(id,value) {calls.push(['window',id,value]);}}, + }; + return {api,current,calls,storage}; +} +const tab = (id, extra={}) => ({id,windowId:1,index:id,url:'https://example.com/',title:'USER RENAMED',active:true,status:'complete',incognito:false,...extra}); + +function service(api) {assert.equal(typeof createTabService,'function');return createTabService(api);} + +test('only active complete HTTP(S) pages are sampled and tabs.title never supplies title', async () => { + const f=fake([tab(1),tab(2,{active:false}),tab(3,{status:'loading'}),tab(4,{url:'chrome://settings/'}),tab(5,{incognito:true})]); + const s=service(f.api); await s.ready; + const rows=await s.list(); + assert.deepEqual(rows.map(row=>row.tabId),[1,2,3]); + assert.equal(rows[0].title,'DOM 1'); + assert.equal(rows[1].titleState,'pending'); + assert.equal(rows[2].titleState,'pending'); + assert.ok(f.calls.filter(c=>c[0]==='script').every(c=>c[1]===1)); +}); + +test('same-URL reload immediately clears title, then inactive tabs retain only current-page titles', async () => { + const f=fake([tab(1)]); const s=service(f.api);await s.ready; + f.current.set(1,tab(1,{active:false,status:'loading'})); + f.api.tabs.onUpdated.emit(1,{status:'loading'},f.current.get(1)); + assert.equal((await s.list())[0].titleState,'pending'); + assert.equal((await s.list())[0].title,''); + f.current.set(1,tab(1));f.api.tabs.onUpdated.emit(1,{status:'complete'},f.current.get(1));await s.idle(); + f.current.set(1,tab(1,{active:false})); + assert.equal((await s.list())[0].title,'DOM 1'); +}); + +test('a delayed sample cannot restore a title after same-URL navigation or closing', async () => { + const f=fake([tab(1)]);const s=service(f.api);await s.ready; + let release; + f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); + f.api.tabs.onUpdated.emit(1,{title:'ignored'},f.current.get(1)); + await new Promise(resolve=>setImmediate(resolve)); + f.current.set(1,tab(1,{active:false,status:'loading'}));f.api.tabs.onUpdated.emit(1,{status:'loading'},f.current.get(1)); + release([{frameId:0,result:{title:'STALE',url:'https://example.com/',readyState:'complete'}}]);await s.idle(); + assert.equal((await s.list())[0].title,''); + f.current.delete(1);f.api.tabs.onRemoved.emit(1);await s.idle(); + assert.deepEqual(await s.list(),[]); +}); + +test('worker restores valid session records and drops closed or URL-mismatched records', async () => { + const saved={diaTabsSession:'session-before',diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'},2:{url:'https://old.example/',title:'OLD',titleState:'captured'},99:{url:'https://example.com/',title:'CLOSED',titleState:'captured'}}}; + const f=fake([tab(1,{active:false}),tab(2,{active:false})],saved);const s=service(f.api);await s.ready; + assert.equal(s.sessionId,'session-before'); + const rows=await s.list();assert.equal(rows[0].title,'Saved DOM');assert.equal(rows[1].titleState,'pending'); + await s.idle();assert.equal(f.storage.diaTabsRecords[99],undefined); +}); + +test('same URL tabs activate by ID using current window, restoring minimized window', async () => { + const f=fake([tab(1),tab(2,{windowId:7,active:false})]);const s=service(f.api);await s.ready; + const request={v:1,id:'test',op:'activate',sessionId:s.sessionId,tabId:2}; + assert.deepEqual(await s.handleRequest(request),{v:1,id:'test',ok:true}); + assert.deepEqual(f.calls.filter(c=>c[0]==='tab'),[['tab',2,{active:true}]]); + assert.deepEqual(f.calls.filter(c=>c[0]==='window'),[['window',7,{state:'normal'}],['window',7,{focused:true}]]); + assert.equal((await s.handleRequest({...request,sessionId:'old'})).error,'STALE_SESSION'); + f.current.delete(2);assert.equal((await s.handleRequest(request)).error,'TAB_CLOSED'); + assert.equal((await s.handleRequest({v:1,id:'bad',op:'eval'})).error,'BAD_REQUEST'); +}); + +test('script rejection is unavailable rather than tabs.title fallback', async () => { + const f=fake([tab(1)]);f.api.scripting.executeScript=async()=>{throw Error('restricted');}; + const s=service(f.api);await s.ready;const row=(await s.list())[0]; + assert.equal(row.title,'');assert.equal(row.titleState,'unavailable'); +}); + +test('title observer ignores style/meta churn and coalesces title mutations without starvation', async () => { + const {runInNewContext} = await import('node:vm'); + const {readPageTitle} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + const callbacks=[],timers=[],messages=[]; + const context={document:{head:{},title:'DOM Title',readyState:'complete'},location:{href:'https://example.com/'}, + MutationObserver:class {constructor(callback) {callbacks.push(callback);}observe() {}}, + setTimeout(fn) {timers.push(fn);return timers.length;},clearTimeout() {}, + chrome:{runtime:{sendMessage(message) {messages.push(message);return Promise.resolve();}}}, + }; + runInNewContext(`(${readPageTitle.toString()})()`,context); + callbacks[0]([{type:'childList',target:{nodeName:'STYLE'},addedNodes:[],removedNodes:[]}]); + assert.equal(timers.length,0); + const mutation={type:'characterData',target:{parentNode:{nodeName:'TITLE'}},addedNodes:[],removedNodes:[]}; + callbacks[0]([mutation]);callbacks[0]([mutation]); + assert.equal(timers.length,1); + timers[0]();assert.equal(messages.length,1); +}); + +test('private-window events never persist URL or title even if API exposes them', async () => { + const f=fake([tab(1)]);const s=service(f.api);await s.ready; + const privateTab=tab(7,{incognito:true,url:'https://example.org/private-demo'}); + f.current.set(7,privateTab);f.api.tabs.onUpdated.emit(7,{url:privateTab.url},privateTab);await s.idle(); + assert.equal(f.storage.diaTabsRecords[7],undefined); + assert.equal((await s.list()).some(row=>row.tabId===7),false); +}); + +test('close during initial query cannot reinsert a stale restored record', async () => { + const f=fake([tab(1,{active:false})]);let release; + f.api.tabs.query=()=>new Promise(resolve=>{release=resolve;}); + const s=service(f.api);await new Promise(resolve=>setImmediate(resolve)); + f.current.delete(1);f.api.tabs.onRemoved.emit(1); + release([tab(1,{active:false})]);await s.ready;await s.idle(); + assert.equal(f.storage.diaTabsRecords[1],undefined); +}); diff --git a/plugins/dia_tabs/tests/bridge.test.mjs b/plugins/dia_tabs/tests/bridge.test.mjs new file mode 100644 index 0000000..364a10d --- /dev/null +++ b/plugins/dia_tabs/tests/bridge.test.mjs @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {runInNewContext} from 'node:vm'; +const source=(await readFile(new URL('../extension/background.js',import.meta.url),'utf8')).replace(/^import .*;\n/,''); + +function harness(unavailable=false) { + const events={},ports=[],alarms=[],notices=[]; + const event=name=>({addListener(fn){events[name]=fn;}}); + const service={ready:Promise.resolve(),refreshTab:id=>notices.push(id),async handleRequest(request){return {v:1,id:request.id,ok:true,sessionId:'session',tabs:[]};}}; + const api={ + runtime:{id:'extension-id',getURL:path=>`chrome-extension://extension-id/${path}`,onMessage:event('message'), + connectNative(name){assert.equal(name,'com.mcoder2014.life_tools.dia_tabs');if(unavailable)throw Error('not installed'); + const messages=[],port={name,messages,postMessage:value=>messages.push(value), + onMessage:{addListener(fn){port.receive=fn;}},onDisconnect:{addListener(fn){port.disconnect=fn;}}};ports.push(port);return port;}}, + alarms:{onAlarm:event('alarm'),create(name,options){alarms.push({name,options});}}, + action:{onClicked:event('action')}, + }; + runInNewContext(source,{chrome:api,createTabService:()=>service}); + return {api,events,ports,alarms,notices}; +} + +test('native bridge forwards protocol, retries only on minute alarm and reports disconnection',async()=>{ + const f=harness();assert.equal(f.ports.length,1);assert.equal(f.alarms[0].options.periodInMinutes,1); + f.ports[0].receive({v:1,id:'native-request',op:'list'});await new Promise(resolve=>setImmediate(resolve)); + assert.equal(f.ports[0].messages[0].id,'native-request');assert.equal(f.ports[0].messages[0].ok,true); + f.api.runtime.lastError={message:'Host disconnected'};f.ports[0].disconnect();delete f.api.runtime.lastError; + let status;f.events.message({type:'dia-tabs-bridge-status'},{id:'extension-id',url:'chrome-extension://extension-id/control.html'},value=>status=value); + assert.equal(status.state,'disconnected');assert.equal(f.ports.length,1); + f.events.alarm({name:'unrelated'});assert.equal(f.ports.length,1); + f.events.alarm({name:'dia-tabs-reconnect'});assert.equal(f.ports.length,2); +}); + +test('control remains usable without native host and content/foreign messages cannot activate tabs',async()=>{ + const f=harness(true);let response; + const control={id:'extension-id',url:'chrome-extension://extension-id/control.html'}; + assert.equal(f.events.message({v:1,id:'ui-request',op:'list'},control,value=>response=value),true); + await new Promise(resolve=>setImmediate(resolve));assert.equal(response.ok,true); + response=undefined; + assert.equal(f.events.message({v:1,id:'bad',op:'activate'}, {id:'other-id',url:control.url},()=>{throw Error('foreign reply');}),false); + assert.equal(f.events.message({v:1,id:'bad',op:'activate'}, {id:'extension-id',url:'https://example.com/',tab:{id:7},frameId:0},()=>{throw Error('content activation');}),false); + f.events.message({type:'dia-tabs-title-changed'},{id:'extension-id',tab:{id:7},frameId:0},()=>{}); + assert.deepEqual(f.notices,[7]); + f.events.message({type:'dia-tabs-title-changed'},{id:'extension-id',tab:{id:8},frameId:1},()=>{}); + assert.deepEqual(f.notices,[7]); +}); diff --git a/plugins/dia_tabs/tests/control.test.mjs b/plugins/dia_tabs/tests/control.test.mjs new file mode 100644 index 0000000..72ce53b --- /dev/null +++ b/plugins/dia_tabs/tests/control.test.mjs @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +const source = await readFile(new URL('../extension/control-core.js', import.meta.url), 'utf8').catch(() => ''); +const core = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); +const cases = JSON.parse(await readFile(new URL('./markdown_cases.json', import.meta.url))); + +test('Markdown escapes titles and destinations consistently with Alfred fixtures', () => { + assert.equal(typeof core.markdownLink, 'function'); + for (const item of cases) assert.equal(core.markdownLink(item.title, item.url), item.expected); +}); + +test('selection supports plain, Ctrl/Cmd toggle and Shift range in displayed order', () => { + assert.equal(typeof core.updateSelection, 'function'); + const ids = [11, 22, 33, 44]; + let state = core.updateSelection(new Set(), null, 22, ids, {}); + assert.deepEqual([...state.selected], [22]); + state = core.updateSelection(state.selected, state.anchor, 44, ids, {metaKey: true}); + assert.deepEqual([...state.selected], [22,44]); + state = core.updateSelection(state.selected, state.anchor, 33, ids, {shiftKey:true}); + assert.deepEqual([...state.selected], [33,44]); + state = core.updateSelection(state.selected, state.anchor, 33, ids, {ctrlKey:true}); + assert.deepEqual([...state.selected], [44]); +}); + +test('copy uses displayed order, keeps duplicate URLs and clears closed selection', () => { + assert.equal(typeof core.selectedOutput, 'function'); + const tabs = [ + {tabId:3, title:'Third', titleState:'captured', url:'https://example.com/'}, + {tabId:1, title:'ignored stale title', titleState:'pending', url:'https://example.org/'}, + {tabId:2, title:'Duplicate', titleState:'captured', url:'https://example.com/'}, + ]; + const selected = core.retainSelection(new Set([1,2,3,99]), tabs); + assert.deepEqual([...selected], [1,2,3]); + assert.equal(core.selectedOutput(tabs, selected, 'url'), 'https://example.com/\nhttps://example.org/\nhttps://example.com/'); + assert.equal(core.selectedOutput(tabs, selected, 'markdown'), '[Third](https://example.com/)\n[https://example.org/](https://example.org/)\n[Duplicate](https://example.com/)'); +}); + +test('control page prevents default URL navigation, preserves modified selection and stops copy bubbling', async () => { + const {runInNewContext} = await import('node:vm'); + class Element { + constructor(tag='div') {this.tag=tag;this.children=[];this.listeners={};this.value='';this.classList={toggle(){}};} + addEventListener(type, fn) {this.listeners[type]=fn;} + setAttribute() {} + append(...children) {this.children.push(...children);} + replaceChildren() {this.children=[];} + } + const ids=['search','tabs','copy-urls','copy-markdown','select-all','empty','count','notice','refresh','reconnect','bridge-status']; + const elements=Object.fromEntries(ids.map(id=>[id,new Element()])); + const requests=[],copied=[]; + const rows=[{tabId:11,windowId:1,index:0,url:'https://example.com/',title:'DOM title',titleState:'captured',active:false}]; + const eventApi={addListener(){}}; + const context={...core,crypto:{randomUUID:()=> 'request'},setTimeout,window:{addEventListener(){}}, + document:{getElementById:id=>elements[id],createElement:tag=>new Element(tag)}, + navigator:{clipboard:{async writeText(text){copied.push(text);}}}, + chrome:{runtime:{async sendMessage(request){requests.push(request);if(request.op==='list')return {ok:true,sessionId:'session',tabs:rows};if(request.op==='activate')return {ok:true};return {state:'disconnected'};}}, + tabs:{onCreated:eventApi,onRemoved:eventApi,onUpdated:eventApi,onActivated:eventApi},storage:{onChanged:eventApi}}, + }; + const uiSource=(await readFile(new URL('../extension/control.js',import.meta.url),'utf8')).replace(/^import .*;\n/,''); + runInNewContext(uiSource,context);await new Promise(resolve=>setImmediate(resolve)); + const first=elements.tabs.children[0]; + const link=first.children[2].children[0]; + const click={defaultPrevented:false,stopped:false,preventDefault(){this.defaultPrevented=true;},stopPropagation(){this.stopped=true;}}; + link.listeners.click(click);await new Promise(resolve=>setImmediate(resolve)); + assert.equal(click.defaultPrevented,true);assert.equal(click.stopped,true); + assert.equal(requests.filter(request=>request.op==='activate').length,1); + assert.equal(requests.find(request=>request.op==='activate').tabId,11); + const modified={...click,defaultPrevented:false,stopped:false,metaKey:true}; + link.listeners.click(modified);assert.equal(modified.defaultPrevented,true);assert.equal(modified.stopped,false); + first.listeners.click(modified); // Native event bubbling reaches the selectable row. + assert.equal(requests.filter(request=>request.op==='activate').length,1); + assert.equal(elements['copy-urls'].disabled,false); + const copyClick={stopped:false,stopPropagation(){this.stopped=true;}}; + elements.tabs.children[0].children[4].children[0].listeners.click(copyClick); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(copyClick.stopped,true);assert.deepEqual(copied,['https://example.com/']); +}); + +test('refresh queues events during list and refreshes captured titles after session change', async () => { + const {runInNewContext}=await import('node:vm'); + class Element { + constructor(){this.children=[];this.listeners={};this.value='';this.classList={toggle(){}};this.renders=0;} + addEventListener(type,fn){this.listeners[type]=fn;}setAttribute(){}append(...items){this.children.push(...items);} + replaceChildren(){this.children=[];this.renders++;} + } + const elements={};const events={}; + const tab={tabId:1,windowId:1,index:0,url:'https://example.com/',title:'Old',titleState:'captured',active:false}; + let resolveFirst, calls=0, rows=[tab]; + const makeEvent=name=>({addListener(fn){events[name]=fn;}}); + const context={...core,crypto:{randomUUID:()=> 'request'},setTimeout,window:{addEventListener(){}}, + document:{getElementById:id=>elements[id] ||= new Element(),createElement:()=>new Element()},navigator:{clipboard:{async writeText(){}}}, + chrome:{runtime:{async sendMessage(request){if(request.op!=='list')return {state:'connected'};calls++;if(calls===1)return new Promise(resolve=>{resolveFirst=resolve;});return {ok:true,sessionId:'s',tabs:rows};}}, + tabs:{onCreated:makeEvent('created'),onRemoved:makeEvent('removed'),onUpdated:makeEvent('updated'),onActivated:makeEvent('activated')},storage:{onChanged:makeEvent('storage')}}, + }; + const ui=(await readFile(new URL('../extension/control.js',import.meta.url),'utf8')).replace(/^import .*;\n/,''); + runInNewContext(ui,context); + rows=[];events.removed(1);resolveFirst({ok:true,sessionId:'s',tabs:[tab]}); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(calls,2);assert.equal(elements.tabs.children.length,0); + rows=[{...tab,title:'New DOM title'}]; + assert.equal(typeof events.storage,'function'); + events.storage({diaTabsRecords:{oldValue:{1:{title:'Old'}},newValue:{1:{title:'New DOM title'}}}},'session'); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(elements.tabs.children[0].children[1].children[0].textContent,'New DOM title'); + const previousCalls=calls,previousRenders=elements.tabs.renders; + events.storage({diaTabsRecords:{oldValue:{1:{title:'New DOM title'}},newValue:{1:{title:'New DOM title'}}}},'session'); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(calls,previousCalls); + events.updated(1,{title:'Unrelated UI title'});await new Promise(resolve=>setImmediate(resolve)); + assert.equal(elements.tabs.renders,previousRenders); +}); diff --git a/plugins/dia_tabs/tests/fixtures/alpha.html b/plugins/dia_tabs/tests/fixtures/alpha.html new file mode 100644 index 0000000..ca7e655 --- /dev/null +++ b/plugins/dia_tabs/tests/fixtures/alpha.html @@ -0,0 +1,17 @@ + + +Dia Tabs Demo Alpha + + +

Dia Tabs · Alpha 公开演示页

+

初始 document.title 为 Dia Tabs Demo Alpha。可在 Dia 手动重命名标签,随后测试扩展仍读取网页标题。

+ +

当前网页标题:Dia Tabs Demo Alpha

+

打开 Beta 新标签打开同 URL 的 Alpha 副本同 URL 重新加载

+ + + diff --git a/plugins/dia_tabs/tests/fixtures/beta.html b/plugins/dia_tabs/tests/fixtures/beta.html new file mode 100644 index 0000000..51f1309 --- /dev/null +++ b/plugins/dia_tabs/tests/fixtures/beta.html @@ -0,0 +1,14 @@ + + +Dia Tabs Demo Beta + + +

Dia Tabs · Beta 公开演示页

用于跨标签和跨窗口激活验证;不发送网络请求。

+ +

打开 Alpha 新标签打开同 URL 的 Beta 副本

+ + + diff --git a/plugins/dia_tabs/tests/markdown_cases.json b/plugins/dia_tabs/tests/markdown_cases.json new file mode 100644 index 0000000..7465d05 --- /dev/null +++ b/plugins/dia_tabs/tests/markdown_cases.json @@ -0,0 +1,32 @@ +[ + { + "title": "Example", + "url": "https://example.com/", + "expected": "[Example](https://example.com/)" + }, + { + "title": "Line one\r\nLine two\tend", + "url": "https://example.com/a b(c)", + "expected": "[Line one Line two\tend](https://example.com/a%20b%28c%29%3Cd%3E)" + }, + { + "title": "A [tag] \\ B", + "url": "https://example.com/%E4%B8%AD?q=one%20two", + "expected": "[A \\[tag\\] \\\\ B](https://example.com/%E4%B8%AD?q=one%20two)" + }, + { + "title": "", + "url": "https://example.com/a(b)", + "expected": "[https://example.com/a(b)](https://example.com/a%28b%29)" + }, + { + "title": "中文标题", + "url": "https://example.com/\t\n", + "expected": "[中文标题](https://example.com/%09%0A%7F)" + }, + { + "title": "Backslash", + "url": "https://example.com/a\\(b)", + "expected": "[Backslash](https://example.com/a%5C%28b%29)" + } +] diff --git a/plugins/dia_tabs/tests/test_install.py b/plugins/dia_tabs/tests/test_install.py new file mode 100644 index 0000000..5336e9f --- /dev/null +++ b/plugins/dia_tabs/tests/test_install.py @@ -0,0 +1,81 @@ +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile +import unittest +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +HOST_NAME = "com.mcoder2014.life_tools.dia_tabs" + + +class InstallTests(unittest.TestCase): + def test_default_host_directory_matches_verified_dia_location(self): + spec = importlib.util.spec_from_file_location("dia_installer", ROOT / "native/install.py") + installer = importlib.util.module_from_spec(spec) + spec.loader.exec_module(installer) + self.assertEqual(installer.DEFAULT_HOST_DIR, + Path.home() / "Library/Application Support/Google/Chrome/NativeMessagingHosts") + + def test_stable_install_preserves_other_workflows_and_pins_python(self): + with tempfile.TemporaryDirectory(prefix="dti-", dir="/tmp") as directory: + target = Path(directory).resolve() + prefix = target / "stable ' runtime" + hosts = target / "hosts" + workflows = target / "workflows" + other = workflows / "user.workflow.other" + other.mkdir(parents=True) + (other / "keep.txt").write_text("existing") + result = subprocess.run([sys.executable, str(ROOT / "native/install.py"), + "--prefix", str(prefix), "--python", sys.executable, + "--native-host-dir", str(hosts), "--workflow-dir", str(workflows)], + capture_output=True, text=True, check=True) + manifest = json.loads((hosts / (HOST_NAME + ".json")).read_text()) + self.assertEqual(manifest["path"], str(prefix / "native-host")) + self.assertEqual(manifest["allowed_origins"], ["chrome-extension://mcalepoloegbbciipaklojhcapcghdpd/"]) + self.assertNotIn(str(ROOT), (prefix / "native-host").read_text()) + self.assertIn(str(Path(sys.executable).resolve()), (prefix / "native-host").read_text()) + self.assertEqual((other / "keep.txt").read_text(), "existing") + with (workflows / "user.workflow.life-tools-dia-tabs/info.plist").open("rb") as stream: + workflow = plistlib.load(stream) + self.assertEqual(workflow["variables"]["DIA_TABS_HOME"], str(prefix)) + listed = subprocess.run([str(prefix / "dia-tabs"), "list", "list"], + capture_output=True, text=True, check=True) + self.assertFalse(json.loads(listed.stdout)["items"][0]["valid"]) + + def test_refuses_foreign_native_manifest(self): + with tempfile.TemporaryDirectory(prefix="dti-", dir="/tmp") as directory: + target = Path(directory) + host = target / "hosts" + host.mkdir() + path = host / (HOST_NAME + ".json") + path.write_text('{"name":"other-tool"}') + result = subprocess.run([sys.executable, str(ROOT / "native/install.py"), "--prefix", str(target / "stable"), + "--native-host-dir", str(host)], capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(json.loads(path.read_text()), {"name": "other-tool"}) + + def test_build_archives_exclude_runtime_state(self): + with tempfile.TemporaryDirectory(prefix="dtb-", dir="/tmp") as directory: + subprocess.run([sys.executable, str(ROOT / "native/build.py"), directory, "test"], + capture_output=True, check=True) + paths = list(Path(directory).iterdir()) + self.assertEqual(len(paths), 3) + for path in paths: + with zipfile.ZipFile(path) as archive: + self.assertIsNone(archive.testzip()) + self.assertFalse(any("__pycache__" in name or name.endswith(".sock") + or name.endswith("install-state.json") or name.endswith("prefs.plist") + for name in archive.namelist())) + with zipfile.ZipFile(Path(directory) / "life_tools_dia_tabs_extension_test.zip") as archive: + self.assertIn("manifest.json", archive.namelist()) + with zipfile.ZipFile(Path(directory) / "life_tools_dia_tabs_test.alfredworkflow") as archive: + self.assertIn("info.plist", archive.namelist()) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/dia_tabs/tests/test_native.py b/plugins/dia_tabs/tests/test_native.py new file mode 100644 index 0000000..3ef3d73 --- /dev/null +++ b/plugins/dia_tabs/tests/test_native.py @@ -0,0 +1,203 @@ +import io +import importlib.util +import json +import os +from pathlib import Path +import select +import socket +import stat +import struct +import subprocess +import sys +import tempfile +import time +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HOST = ROOT / "native" / "host.py" + + +def frame(value): + payload = json.dumps(value, ensure_ascii=False).encode("utf-8") + return struct.pack("=I", len(payload)) + payload + + +def read_frame(stream, timeout=3): + if not select.select([stream], [], [], timeout)[0]: + raise AssertionError("native response timed out") + length = struct.unpack("=I", stream.read(4))[0] + return json.loads(stream.read(length)) + + +class NativeIntegrationTests(unittest.TestCase): + def setUp(self): + self.assertTrue(HOST.exists(), "native host 尚未实现") + self.temp = tempfile.TemporaryDirectory(prefix="dt-", dir="/tmp") + self.run_dir = Path(self.temp.name) / "run" + env = dict(os.environ, DIA_TABS_RUN_DIR=str(self.run_dir), DIA_TABS_TIMEOUT="0.3") + self.proc = subprocess.Popen([sys.executable, str(HOST)], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, bufsize=0) + deadline = time.monotonic() + 3 + self.paths = [] + while time.monotonic() < deadline: + self.paths = list(self.run_dir.glob("*.sock")) + if self.paths: + break + if self.proc.poll() is not None: + self.fail(self.proc.stderr.read().decode()) + time.sleep(0.01) + self.assertEqual(len(self.paths), 1) + + def tearDown(self): + if hasattr(self, "proc"): + if not self.proc.stdin.closed: + self.proc.stdin.close() + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait() + self.proc.stdout.close() + self.proc.stderr.close() + if hasattr(self, "temp"): + self.temp.cleanup() + + def client(self, request): + client = socket.socket(socket.AF_UNIX) + client.settimeout(3) + client.connect(str(self.paths[0])) + client.sendall(json.dumps(request).encode() + b"\n") + return client + + def response(self, client): + with client: + return json.loads(client.makefile("rb").readline()) + + def test_forwards_unicode_and_fragmented_native_frame(self): + client = self.client({"v": 1, "id": "client-id", "op": "list"}) + request = read_frame(self.proc.stdout) + self.assertNotEqual(request["id"], "client-id") + response = {"v": 1, "id": request["id"], "ok": True, + "sessionId": "s", "tabs": [{"title": "中文标题"}]} + payload = frame(response) + for chunk in [payload[:2], payload[2:7], payload[7:]]: + self.proc.stdin.write(chunk) + self.proc.stdin.flush() + actual = self.response(client) + self.assertEqual(actual, dict(response, id="client-id")) + + def test_duplicate_client_ids_are_isolated(self): + clients = [self.client({"v": 1, "id": "same", "op": "list"}), + self.client({"v": 1, "id": "same", "op": "activate", "sessionId": "s", "tabId": 9})] + requests = [read_frame(self.proc.stdout) for _ in clients] + self.assertNotEqual(requests[0]["id"], requests[1]["id"]) + for request in reversed(requests): + self.proc.stdin.write(frame({"v": 1, "id": request["id"], "ok": True, + "sessionId": request["op"], "tabs": []})) + self.proc.stdin.flush() + results = [self.response(client) for client in clients] + self.assertEqual([result["sessionId"] for result in results], ["list", "activate"]) + self.assertTrue(all(result["id"] == "same" for result in results)) + + def test_rejects_unknown_operation(self): + result = self.response(self.client({"v": 1, "id": "bad", "op": "exec"})) + self.assertEqual(result["error"], "BAD_REQUEST") + self.assertFalse(select.select([self.proc.stdout], [], [], 0.05)[0]) + + def test_activate_preserves_stale_session_error(self): + client = self.client({"v": 1, "id": "a", "op": "activate", "sessionId": "old", "tabId": 7}) + request = read_frame(self.proc.stdout) + self.assertEqual(request["sessionId"], "old") + self.proc.stdin.write(frame({"v": 1, "id": request["id"], "ok": False, + "error": "STALE_SESSION"})) + self.proc.stdin.flush() + self.assertEqual(self.response(client)["error"], "STALE_SESSION") + + def test_timeout_returns_error(self): + client = self.client({"v": 1, "id": "slow", "op": "list"}) + read_frame(self.proc.stdout) + self.assertEqual(self.response(client)["error"], "TIMEOUT") + + def test_eof_wakes_waiter_and_removes_socket(self): + client = self.client({"v": 1, "id": "eof", "op": "list"}) + read_frame(self.proc.stdout) + self.proc.stdin.close() + self.assertEqual(self.response(client)["error"], "BRIDGE_UNAVAILABLE") + self.proc.wait(timeout=3) + self.assertFalse(self.paths[0].exists()) + + def test_private_permissions(self): + self.assertEqual(stat.S_IMODE(self.run_dir.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(self.paths[0].stat().st_mode), 0o600) + + def test_invalid_native_frame_exits_and_cleans_up(self): + self.proc.stdin.write(struct.pack("=I", 2 ** 21)) + self.proc.stdin.flush() + self.proc.wait(timeout=3) + self.assertFalse(self.paths[0].exists()) + + def test_invalid_json_client_is_rejected(self): + client = socket.socket(socket.AF_UNIX) + client.settimeout(2) + client.connect(str(self.paths[0])) + client.sendall(b"{broken}\n") + self.assertEqual(self.response(client)["error"], "BAD_REQUEST") + + def test_invalid_native_json_wakes_waiter(self): + client = self.client({"v": 1, "id": "eof", "op": "list"}) + read_frame(self.proc.stdout) + self.proc.stdin.write(struct.pack("=I", 1) + b"{") + self.proc.stdin.flush() + self.assertEqual(self.response(client)["error"], "BRIDGE_UNAVAILABLE") + self.proc.wait(timeout=3) + self.assertFalse(self.paths[0].exists()) + + def test_oversized_client_request_is_rejected(self): + client = socket.socket(socket.AF_UNIX) + client.settimeout(3) + client.connect(str(self.paths[0])) + try: + client.sendall(b"x" * (1024 * 1024 + 1)) + except BrokenPipeError: + pass + self.assertEqual(self.response(client)["error"], "BAD_REQUEST") + + +class NativeProtocolTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location("dia_native", HOST) + cls.host = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.host) + + def test_exact_reader_accepts_split_utf8_payload(self): + class Fragments(io.BytesIO): + def read(self, count=-1): + return super().read(min(count, 2)) + value = {"v": 1, "id": "中文", "ok": True} + self.assertEqual(self.host.read_native(Fragments(frame(value))), value) + + def test_partial_native_frames_are_rejected(self): + for data in (b"\x01", struct.pack("=I", 5) + b"{}", struct.pack("=I", 0), + struct.pack("=I", 1024 * 1024 + 1)): + with self.subTest(data=data), self.assertRaises(ValueError): + self.host.read_native(io.BytesIO(data)) + + def test_request_validation_rejects_paths_boolean_ids_and_extra_fields(self): + for request in ({"v": True, "id": "r", "op": "list"}, + {"v": 1, "id": "r", "op": "list", "path": "/tmp/file"}, + {"v": 1, "id": "r", "op": "activate", "sessionId": "s", "tabId": True}, + {"v": 1, "id": "", "op": "list"}): + with self.subTest(request=request): + self.assertFalse(self.host.validate_request(request)) + + def test_socket_length_uses_utf8_bytes_and_rejects_before_mkdir(self): + with tempfile.TemporaryDirectory(prefix="dtp-", dir="/tmp") as directory: + path = Path(directory) / ("中" * 32) + with self.assertRaises(ValueError): + self.host.Bridge(path).run() + self.assertFalse(path.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/dia_tabs/tests/test_workflow.py b/plugins/dia_tabs/tests/test_workflow.py new file mode 100644 index 0000000..6f793b2 --- /dev/null +++ b/plugins/dia_tabs/tests/test_workflow.py @@ -0,0 +1,125 @@ +import importlib.util +import io +import json +import os +from pathlib import Path +import socket +import tempfile +import threading +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "workflow" / "scripts" / "dia_tabs.py" + + +class WorkflowTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not SCRIPT.exists(): + return + spec = importlib.util.spec_from_file_location("dia_workflow", SCRIPT) + cls.workflow = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.workflow) + + def setUp(self): + self.assertTrue(SCRIPT.exists(), "Alfred workflow 尚未实现") + + def tab(self, **changes): + return dict(tabId=7, windowId=2, index=0, url="https://example.test/path", + title="原始标题", titleState="captured", active=True, **changes) + + def test_keyword_parsing_and_filter(self): + self.assertIsNone(self.workflow.parse_query("")) + self.assertEqual(self.workflow.parse_query("list"), "") + self.assertEqual(self.workflow.parse_query("list Example"), "Example") + self.assertIsNone(self.workflow.parse_query("listing")) + item = self.workflow.tab_item("abc123", "session", self.tab()) + self.assertIn("原始标题", item["title"]) + self.assertEqual(json.loads(item["arg"])["tabId"], 7) + self.assertEqual(json.loads(item["arg"])["action"], "activate") + + def test_modifiers_use_json_arguments(self): + title = "`$(touch /tmp/nope)` [title]" + tab = self.tab() + tab["title"] = title + item = self.workflow.tab_item("abcdef", "session", tab) + url_arg = json.loads(item["mods"]["cmd"]["arg"]) + md_arg = json.loads(item["mods"]["alt"]["arg"]) + self.assertEqual(url_arg, {"action": "copy", "text": tab["url"]}) + self.assertEqual(md_arg["text"], self.workflow.markdown(title, tab["url"])) + + def test_markdown_shared_cases(self): + cases = json.loads((ROOT / "tests" / "markdown_cases.json").read_text()) + for case in cases: + with self.subTest(case=case): + self.assertEqual(self.workflow.markdown(case["title"], case["url"]), case["expected"]) + + def test_pending_title_uses_url_for_markdown(self): + tab = self.tab() + tab.update(title="", titleState="pending") + item = self.workflow.tab_item("abcdef", "session", tab) + self.assertIn("待访问", item["title"]) + self.assertEqual(json.loads(item["mods"]["alt"]["arg"])["text"], + "[https://example.test/path](https://example.test/path)") + + def test_disconnected_socket_never_shows_old_data(self): + with tempfile.TemporaryDirectory(prefix="dw-", dir="/tmp") as directory: + server = socket.socket(socket.AF_UNIX) + path = Path(directory) / "deadbeef.sock" + server.bind(str(path)) + server.close() + result = self.workflow.list_items("", Path(directory)) + self.assertEqual(len(result["items"]), 1) + self.assertFalse(result["items"][0]["valid"]) + self.assertIn("桥接", result["items"][0]["title"]) + + def test_list_queries_live_connection_and_filters_case_insensitively(self): + with tempfile.TemporaryDirectory(prefix="dw-", dir="/tmp") as directory: + server = socket.socket(socket.AF_UNIX) + server.bind(str(Path(directory) / "abcdef.sock")) + server.listen() + def serve(): + with server: + client, _ = server.accept() + with client: + request = json.loads(client.makefile("rb").readline()) + client.sendall(json.dumps({"v": 1, "id": request["id"], "ok": True, + "sessionId": "session", "tabs": [self.tab()]}).encode() + b"\n") + thread = threading.Thread(target=serve) + thread.start() + result = self.workflow.list_items("EXAMPLE", Path(directory)) + thread.join(timeout=2) + self.assertEqual(len(result["items"]), 1) + self.assertTrue(result["items"][0]["valid"]) + + def test_copy_uses_stdin_and_reports_pbcopy_failure(self): + with mock.patch.object(self.workflow.subprocess, "run") as run, mock.patch("sys.stderr", new=io.StringIO()): + run.return_value.returncode = 0 + self.assertEqual(self.workflow.perform({"action": "copy", "text": "a `b` $(c)"}), 0) + self.assertEqual(run.call_args.args[0], ["/usr/bin/pbcopy"]) + self.assertEqual(run.call_args.kwargs["input"], "a `b` $(c)".encode()) + self.assertNotIn("shell", run.call_args.kwargs) + run.return_value.returncode = 1 + self.assertNotEqual(self.workflow.perform({"action": "copy", "text": "x"}), 0) + + def test_activate_rejects_connection_path_traversal(self): + with mock.patch("sys.stderr", new=io.StringIO()): + self.assertNotEqual(self.workflow.perform({"action": "activate", "connectionId": "../other", + "sessionId": "s", "tabId": 1}), 0) + + def test_activation_sends_exact_identity_without_url_lookup(self): + with mock.patch.object(self.workflow, "request_socket") as request: + request.return_value = {"v": 1, "id": "reply", "ok": True} + value = {"action": "activate", "connectionId": "abcdef123456", + "sessionId": "current-session", "tabId": 123, "url": "https://same.test/"} + self.assertEqual(self.workflow.perform(value, Path("/tmp/dia-test-run")), 0) + self.assertEqual(request.call_args.args[0], Path("/tmp/dia-test-run/abcdef123456.sock")) + payload = request.call_args.args[1] + self.assertEqual(set(payload), {"v", "id", "op", "sessionId", "tabId"}) + self.assertEqual(payload["sessionId"], "current-session") + self.assertEqual(payload["tabId"], 123) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/dia_tabs/workflow/info.plist b/plugins/dia_tabs/workflow/info.plist new file mode 100644 index 0000000..00234e3 --- /dev/null +++ b/plugins/dia_tabs/workflow/info.plist @@ -0,0 +1,135 @@ + + + + + bundleid + com.mcoder2014.life-tools.dia-tabs + category + Productivity + connections + + 41FC3C6D-DA72-40CC-9340-5ED454E01D4F + + + destinationuid + 92ECC6AD-1984-4CF2-B1A1-D648FB39401A + modifiers + 0 + modifiersubtext + + vitoclose + + + + + createdby + mcoder2014 + description + 列出 Dia 当前打开的网页,按原始网页标题复制并精确激活标签。 + disabled + + name + Dia Tabs + objects + + + config + + alfredfiltersresults + + argumenttreatemptyqueryasnil + + argumenttrimmode + 0 + argumenttype + 1 + escaping + 0 + keyword + dia + queuedelaycustom + 1 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + 正在查询 Dia 当前网页… + script + + scriptargtype + 1 + scriptfile + scripts/list.sh + subtext + 输入 list 列出和搜索当前打开的网页 + title + Dia Tabs + type + 8 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 41FC3C6D-DA72-40CC-9340-5ED454E01D4F + version + 3 + + + config + + concurrently + + escaping + 0 + script + + scriptargtype + 1 + scriptfile + scripts/action.sh + type + 8 + + type + alfred.workflow.action.script + uid + 92ECC6AD-1984-4CF2-B1A1-D648FB39401A + version + 2 + + + readme + 先运行 Dia Tabs 安装器并在 Dia 加载稳定目录的扩展。输入 dia list 搜索;回车激活已有标签,Cmd+回车复制 URL,Alt+回车复制 Markdown。 + uidata + + 41FC3C6D-DA72-40CC-9340-5ED454E01D4F + + xpos + 100 + ypos + 120 + + 92ECC6AD-1984-4CF2-B1A1-D648FB39401A + + xpos + 400 + ypos + 120 + + + variables + + DIA_TABS_HOME + + + version + 1.0.0 + webaddress + https://github.com/mcoder2014/life_tools + + diff --git a/plugins/dia_tabs/workflow/scripts/action.sh b/plugins/dia_tabs/workflow/scripts/action.sh new file mode 100755 index 0000000..b3f2ed1 --- /dev/null +++ b/plugins/dia_tabs/workflow/scripts/action.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +exec "${DIA_TABS_HOME:-$HOME/.local/share/life-tools-dia-tabs}/dia-tabs" action "${1:-}" diff --git a/plugins/dia_tabs/workflow/scripts/dia_tabs.py b/plugins/dia_tabs/workflow/scripts/dia_tabs.py new file mode 100644 index 0000000..7b71ac4 --- /dev/null +++ b/plugins/dia_tabs/workflow/scripts/dia_tabs.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Alfred Script Filter and exact-tab actions. No browser history is persisted.""" + +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +import re +import socket +import stat +import subprocess +import sys +import uuid + +MAX_MESSAGE = 1024 * 1024 +RUN_DIR = Path(os.environ.get("DIA_TABS_RUN_DIR", Path.home() / ".local/share/life-tools-dia-tabs/run")) +CONNECTION_ID = re.compile(r"[a-f0-9]{6,32}\Z") + + +def markdown(title, url): + text = re.sub(r"\r\n|\r|\n", " ", title or url) + text = text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + target = "".join("%{:02X}".format(ord(char)) if ord(char) <= 0x20 or ord(char) == 0x7F + or char in "()<>\\" else char for char in url) + return "[{}]({})".format(text, target) + + +def parse_query(query): + value = query.strip() + if value == "list": + return "" + if value.startswith("list "): + return value[5:].strip() + return None + + +def request_socket(path, request, timeout=1.25): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(timeout) + client.connect(str(path)) + client.sendall(json.dumps(request).encode("utf-8") + b"\n") + with client.makefile("rb") as stream: + line = stream.readline(MAX_MESSAGE + 1) + if len(line) > MAX_MESSAGE or not line.endswith(b"\n"): + raise ValueError("invalid bridge message length") + value = json.loads(line.decode("utf-8")) + if (not isinstance(value, dict) or value.get("v") != 1 or value.get("id") != request["id"] + or type(value.get("ok")) is not bool): + raise ValueError("invalid bridge response") + return value + + +def tab_item(connection_id, session_id, tab): + title_state = tab.get("titleState") + title = tab.get("title", "") if title_state == "captured" else "" + display = title or {"pending": "待访问", "unavailable": "无法读取页面标题"}.get(title_state, "无标题") + identity = {"action": "activate", "connectionId": connection_id, + "sessionId": session_id, "tabId": tab["tabId"]} + encode = lambda value: json.dumps(value, ensure_ascii=False) + return {"uid": "{}:{}:{}".format(connection_id, session_id, tab["tabId"]), + "title": display, "subtitle": "{} · 窗口 {} · {}".format(tab["url"], tab["windowId"], connection_id[:6]), + "arg": encode(identity), "valid": True, + "mods": {"cmd": {"valid": True, "subtitle": "复制 URL", + "arg": encode({"action": "copy", "text": tab["url"]})}, + "alt": {"valid": True, "subtitle": "复制 Markdown", + "arg": encode({"action": "copy", "text": markdown(title, tab["url"])})}}} + + +def read_connection(path): + try: + value = request_socket(path, {"v": 1, "id": uuid.uuid4().hex, "op": "list"}) + if value.get("ok") and isinstance(value.get("sessionId"), str) and isinstance(value.get("tabs"), list): + return [(path.stem, value["sessionId"], tab) for tab in value["tabs"] + if isinstance(tab, dict) and type(tab.get("tabId")) is int + and type(tab.get("windowId")) is int and isinstance(tab.get("url"), str) + and tab["url"].startswith(("http://", "https://")) + and isinstance(tab.get("title", ""), str)] + except (OSError, ValueError, UnicodeError): + pass + return None + + +def list_items(query, run_dir=RUN_DIR): + paths = [] + for path in sorted(Path(run_dir).glob("*.sock")): + try: + info = path.lstat() + if CONNECTION_ID.fullmatch(path.stem) and stat.S_ISSOCK(info.st_mode) and info.st_uid == os.getuid(): + paths.append(path) + except OSError: + continue + needle = query.casefold() + items, connected = [], False + with ThreadPoolExecutor(max_workers=8) as executor: + for tabs in executor.map(read_connection, paths): + if tabs is None: + continue + connected = True + for connection_id, session_id, tab in tabs: + if needle in (tab.get("title", "") + " " + tab["url"]).casefold(): + items.append(tab_item(connection_id, session_id, tab)) + if not items: + title = "没有匹配的网页" if connected else "Dia 本机桥接未连接" + items.append({"title": title, "subtitle": "请在 Dia 中启用扩展并检查控制页的桥接状态", "valid": False}) + return {"items": items} + + +def perform(value, run_dir=RUN_DIR): + if not isinstance(value, dict): + print("无效的操作参数", file=sys.stderr) + return 1 + try: + if value.get("action") == "copy" and isinstance(value.get("text"), str): + result = subprocess.run(["/usr/bin/pbcopy"], input=value["text"].encode("utf-8"), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=3, check=False) + if result.returncode: + raise ValueError("复制失败,请检查系统剪贴板") + return 0 + connection_id = value.get("connectionId", "") + if (value.get("action") != "activate" or not isinstance(connection_id, str) + or not CONNECTION_ID.fullmatch(connection_id) + or not isinstance(value.get("sessionId"), str) or type(value.get("tabId")) is not int): + raise ValueError("无效的操作参数") + response = request_socket(Path(run_dir) / (connection_id + ".sock"), + {"v": 1, "id": uuid.uuid4().hex, "op": "activate", + "sessionId": value["sessionId"], "tabId": value["tabId"]}, timeout=3) + if not response["ok"]: + message = {"TAB_CLOSED": "标签已关闭,请重新查询", "STALE_SESSION": "浏览器会话已变化,请重新查询", + "TIMEOUT": "浏览器响应超时", "BRIDGE_UNAVAILABLE": "浏览器连接已断开"} + raise ValueError(message.get(response.get("error"), "激活失败,请重新查询")) + return 0 + except (OSError, ValueError, UnicodeError, subprocess.TimeoutExpired) as error: + print(str(error), file=sys.stderr) + return 1 + + +def main(): + operation = sys.argv[1] if len(sys.argv) > 1 else "list" + argument = sys.argv[2] if len(sys.argv) > 2 else "" + if operation == "action": + try: + return perform(json.loads(argument)) + except ValueError: + print("无效的操作参数", file=sys.stderr) + return 1 + query = parse_query(argument) + value = ({"items": [{"title": "列出 Dia 网页", "subtitle": "输入 dia list 搜索当前打开的网页", + "autocomplete": "list ", "valid": False}]} if query is None else list_items(query)) + print(json.dumps(value, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/dia_tabs/workflow/scripts/list.sh b/plugins/dia_tabs/workflow/scripts/list.sh new file mode 100755 index 0000000..1e4d50a --- /dev/null +++ b/plugins/dia_tabs/workflow/scripts/list.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +exec "${DIA_TABS_HOME:-$HOME/.local/share/life-tools-dia-tabs}/dia-tabs" list "${1:-}" From d75eb89b0483d1e4b19288730ab844fe8dac81f2 Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:54:35 +0800 Subject: [PATCH 2/9] docs: clarify Dia manual verification coverage --- docs/plugins/dia_tabs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/dia_tabs.md b/docs/plugins/dia_tabs.md index f471cee..2e97c5e 100644 --- a/docs/plugins/dia_tabs.md +++ b/docs/plugins/dia_tabs.md @@ -121,7 +121,7 @@ Python 集成测试需要在临时目录创建 Unix socket,不操作真实浏 | 范围 | 实机状态 | |---|---| | Native 桥接与 Alfred 列表 | Chrome 用户目录注册成功;Alfred 搜索可以显示演示网页 | -| DOM 标题与控制页 | 动态标题由 Alpha 更新为包含方括号和中文的标题;点击 URL 返回同一标签;多选 Markdown 在真实文本框粘贴准确;关闭演示标签后列表由 1 项变为 0 项 | +| DOM 标题与控制页 | 动态标题由 Alpha 更新为包含方括号和中文的标题;点击 URL 返回同一标签;所选单项 Markdown 在真实文本框粘贴准确;关闭演示标签后列表由 1 项变为 0 项 | | 待验证 | 手动重命名后动态标题、跨窗口/最小化、浏览器退出后的清理、Alfred 回车实际切换及 Cmd/Alt+回车实际复制 | Alfred 修饰键复制已通过参数和 `pbcopy` 的离线测试;组合键的实机交互尚未确认,不能据此声称验收完成。 From 19a59173d7fa72eba7d6ad0f5f99df9229452e51 Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:14:29 +0800 Subject: [PATCH 3/9] feat: sync titles from already open Dia tabs --- docs/plugins/dia_tabs.md | 8 +- docs/specs/2026-11-01-dia-tabs-design.md | 14 ++- plugins/dia_tabs/extension/background.js | 6 ++ plugins/dia_tabs/extension/control.html | 4 +- plugins/dia_tabs/extension/control.js | 25 +++++- plugins/dia_tabs/extension/state.js | 62 +++++++++++--- plugins/dia_tabs/tests/background.test.mjs | 99 ++++++++++++++++++++++ plugins/dia_tabs/tests/bridge.test.mjs | 16 +++- plugins/dia_tabs/tests/control.test.mjs | 29 ++++++- 9 files changed, 240 insertions(+), 23 deletions(-) diff --git a/docs/plugins/dia_tabs.md b/docs/plugins/dia_tabs.md index 2e97c5e..a7cd0e3 100644 --- a/docs/plugins/dia_tabs.md +++ b/docs/plugins/dia_tabs.md @@ -9,6 +9,7 @@ Dia Tabs 保存打开网页的原始 `document.title`,在扩展控制页和 Al | 入口 | 操作 | 结果 | |---|---|---| | 扩展控制页 | 点击标签标题或 URL | 激活该已有标签及其窗口 | +| 扩展控制页 | 同步已打开标签 | 读取当前 profile 已加载网页的原始标题,包含后台标签;显示本轮成功、无法读取和跳过数量 | | 扩展控制页 | 单击行、Cmd/Ctrl 切换、Shift 连选 | 选择当前列表中的一项或多项 | | 扩展控制页 | 复制所选 URL / Markdown | 按列表顺序输出,一项一行;同 URL 的多个标签保留多项 | | Alfred | `dia list` | 汇总已连接扩展的各 profile 当前网页 | @@ -60,7 +61,9 @@ plugins/dia_tabs/install.sh \ ## 标题何时更新 -只在标签是所在窗口的 active 标签、且加载完成时读取顶层网页 DOM;首次安装不会遍历访问所有后台网页。多个窗口各自选中的标签都可以采集。缓存存于 `chrome.storage.session`,浏览器重启或扩展重载后重新采集,不建立网页历史数据库。 +自动采集只在标签是所在窗口的 active 标签、且加载完成时读取顶层网页 DOM;首次安装不会遍历访问所有后台网页。多个窗口各自选中的标签都可以采集。缓存存于 `chrome.storage.session`,浏览器重启或扩展重载后重新采集,不建立网页历史数据库。 + +点击“同步已打开标签”可一次读取本 profile 已打开 HTTP/HTTPS 网页的 `document.title`,包括后台标签;不切换、加载或重载页面。加载中、已释放(discarded)、冻结(frozen)或读取超过 5 秒的页面会跳过,受限页面计为“无法读取”;采集期间导航、关闭或被更新采样替代的结果也计为跳过。最多同时保留 4 个未结束的手动读取;超时读取仍占用额度,额度不足时跳过其余页面。完成后刷新列表并保留仍存在的选择,Alfred 后续查询共用更新后的标题;“刷新列表”保持原有自动采集规则。 | 状态 | 列表显示 | 复制 Markdown 的链接文字 | |---|---|---| @@ -82,7 +85,7 @@ plugins/dia_tabs/install.sh \ | 控制页正常,Alfred 显示桥接未连接 | 检查 native manifest 目录与 `path`;确认安装时指定的 Python 仍存在,再点“重新连接” | | 扩展显示 native host 未找到 | 核对注册目录是否适用于本机 Dia;不要同时向多个目录重复注册 | | Alfred 查询不到某个 profile | 在该 profile 启用扩展并确认桥接连接;隐私窗口不支持 | -| 结果显示“待访问” | 切到该网页并等待加载完成,再刷新列表 | +| 结果显示“待访问” | 点击“同步已打开标签”;加载中、已释放或冻结的页面需正常打开并加载完成后再试 | | 回车后标签未激活 | 标签可能已关闭,或浏览器会话已变化;重新执行 `dia list`,不会用 URL 新开标签兜底 | | 复制失败 | 查看 Alfred Workflow debugger;`pbcopy` 失败返回非零状态,不报告复制成功 | | 移动或删除了安装时的 Python | 使用新的 `--python` 路径重新运行安装器 | @@ -122,6 +125,7 @@ Python 集成测试需要在临时目录创建 Unix socket,不操作真实浏 |---|---| | Native 桥接与 Alfred 列表 | Chrome 用户目录注册成功;Alfred 搜索可以显示演示网页 | | DOM 标题与控制页 | 动态标题由 Alpha 更新为包含方括号和中文的标题;点击 URL 返回同一标签;所选单项 Markdown 在真实文本框粘贴准确;关闭演示标签后列表由 1 项变为 0 项 | +| 手动同步 | 同步按钮、完成计数和 Alfred 共用缓存已实测;加载完成的后台页成功采集及各跳过状态的实机覆盖待验证 | | 待验证 | 手动重命名后动态标题、跨窗口/最小化、浏览器退出后的清理、Alfred 回车实际切换及 Cmd/Alt+回车实际复制 | Alfred 修饰键复制已通过参数和 `pbcopy` 的离线测试;组合键的实机交互尚未确认,不能据此声称验收完成。 diff --git a/docs/specs/2026-11-01-dia-tabs-design.md b/docs/specs/2026-11-01-dia-tabs-design.md index e126f82..f5c9ed4 100644 --- a/docs/specs/2026-11-01-dia-tabs-design.md +++ b/docs/specs/2026-11-01-dia-tabs-design.md @@ -40,9 +40,11 @@ | 状态或事件 | 行为 | |---|---| | 打开的 HTTP/HTTPS 标签尚未被采集 | 显示“待访问”,保留 URL,可复制 URL、可激活 | -| active 且 tab.status=complete | 在顶层 frame 读取 `document.title`、`location.href`、`document.readyState` | +| 自动采集:active 且 tab.status=complete,未 discarded/frozen | 在顶层 frame 读取 `document.title`、`location.href`、`document.readyState` | | 初次启动、`onActivated`、`onUpdated` 的 complete/title 变化 | 满足上述条件时重新采集,不使用事件中的 title 值 | -| 非 active 标签 | 不主动采集;保留其已采集且仍对应当前 URL 的标题 | +| 非 active 标签 | 不自动采集;保留其已采集且仍对应当前 URL 的标题 | +| 点击“同步已打开标签” | 手动读取当前 profile 全部 HTTP/HTTPS 已加载网页标题,包含后台标签;不切换、加载或重载页面 | +| 手动同步遇到 loading/discarded/frozen | 跳过,保持页面状态;单次读取超过 5 秒也跳过 | | 加载开始或 URL 变化 | 立即作废旧标题,增加该 tab 的采集版本;禁止旧标题与新 URL 配对 | | SPA 改标题 | active 时由 `onUpdated` 的 title 变化触发重新读取 DOM | | SPA 改 URL | 作废旧标题,active 且 complete 时重新读取;后续 title 事件继续更新 | @@ -60,7 +62,9 @@ 记录至少包含 `tabId, url, title, titleState`。列表输出额外包含实时 `windowId, index, active`。导航版本计数用于拒绝延迟返回,不必对外暴露。 -采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、版本和 URL 均相同、DOM readyState 为 complete 时接受。导航事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 +采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。显式手动同步仅放宽 active 门槛,并在注入前再次读取实时标签状态;导航、关闭和更晚采样校验不变。导航事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 + +手动同步最多并发 4 个实际未结束的脚本读取,重复请求复用同一批次。读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,额度不足时其余页面计为跳过,防止超时累积无限注入。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。 service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录。不能只依赖 `onStartup`:休眠后重启 worker 同样要初始化。 @@ -74,6 +78,8 @@ service worker 的事件监听器在模块顶层同步注册。初始化载入 s Native host 名称固定为 `com.mcoder2014.life_tools.dia_tabs`。协议使用 JSON,固定版本 `v:1` 和字符串请求 `id`。只接受 `list`、`activate` 两个操作,拒绝任意脚本和任意文件路径。控制页 runtime message 与 native 请求共用此协议。 +手动同步使用控制页专属 runtime 消息 `{type:"dia-tabs-sync"}`,只在验证扩展自身 sender 后处理,不加入 native/Alfred 协议;网站 content script 不可调用。 + ```json {"v":1,"id":"request-uuid","op":"list"} {"v":1,"id":"request-uuid","ok":true,"sessionId":"session-uuid","tabs":[]} @@ -93,6 +99,8 @@ socket 放在用户目录的私有短路径,例如 `~/.local/share/life-tools- 提供“复制所选 URL”和“复制所选 Markdown”。按当前表格顺序输出,每项一行;不同 tab 即使同 URL 也保留为不同项。刷新时按 tabId 保留仍存在的选择,移除已关闭项。无已采集标题的条目使用 URL 作为 Markdown 文本,不把“待访问”写入结果。 +“同步已打开标签”按钮在执行期间禁用并显示忙碌状态,完成后显示本轮成功、无法读取和跳过数量,刷新列表并保留仍存在的选择;失败后恢复按钮并允许重试。 + Alfred keyword 为 `dia`,参数先识别 `list`,`dia list` 展示全部,`dia list <查询>` 按 title 和 URL 进行不区分大小写的过滤。无参数时返回可自动补全到 `dia list ` 的入口。使用 Script Filter JSON 的 `mods`:回车激活,Cmd+回车复制 URL,Alt+回车复制 Markdown。所有动作以 JSON 参数通过 argv 传递,不把 title/URL 插进 shell 代码。复制用 `/usr/bin/pbcopy` 的 stdin。 扩展和 Alfred 的 Markdown 转义规则必须一致:标题中的换行归一为空格、转义反斜线及方括号;URL 中会破坏链接语法的空格、括号、尖括号和控制字符使用百分号编码,保留原有合法百分号编码。页面渲染用 textContent,不用网页 title 拼接 HTML。两端共用测试用例数据来验证一致性,无需为此增加公共运行库。 diff --git a/plugins/dia_tabs/extension/background.js b/plugins/dia_tabs/extension/background.js index ebc76fb..417e9f4 100644 --- a/plugins/dia_tabs/extension/background.js +++ b/plugins/dia_tabs/extension/background.js @@ -45,6 +45,12 @@ chrome.runtime.onMessage.addListener((request, sender, reply) => { // Content scripts can only notify. List/activation/bridge controls belong to // the extension's own page; website input cannot choose a tab ID to activate. if (!sender.url?.startsWith(chrome.runtime.getURL(''))) return false; + if (request?.type === 'dia-tabs-sync') { + service.syncOpenTabs().then(counts => reply({ok: true, ...counts})).catch(() => { + reply({ok: false, error: 'INTERNAL_ERROR', message: '同步失败,请稍后重试'}); + }); + return true; + } if (request?.type === 'dia-tabs-bridge-status' || request?.type === 'dia-tabs-reconnect') { if (request.type === 'dia-tabs-reconnect') connectBridge(); reply({state: bridgeState, message: bridgeMessage}); diff --git a/plugins/dia_tabs/extension/control.html b/plugins/dia_tabs/extension/control.html index eaaa567..307e821 100644 --- a/plugins/dia_tabs/extension/control.html +++ b/plugins/dia_tabs/extension/control.html @@ -10,7 +10,7 @@

LIFE TOOLS · DIA

网页标题与标签

- +

记录页面原始标题。点击标题或网址,切换到已有标签。

@@ -30,7 +30,7 @@ -

未访问的后台页面显示“待访问”;切换到该页面且加载完成后记录标题。隐私窗口不在列表内。

+

后台页面可用“同步已打开标签”读取标题;加载中、已释放或冻结的页面会跳过,不切换或重载页面。隐私窗口不在列表内。

diff --git a/plugins/dia_tabs/extension/control.js b/plugins/dia_tabs/extension/control.js index e398c5a..260af02 100644 --- a/plugins/dia_tabs/extension/control.js +++ b/plugins/dia_tabs/extension/control.js @@ -2,7 +2,7 @@ import {updateSelection, retainSelection, selectedOutput} from './control-core.j const byId = id => document.getElementById(id); let tabs = [], selected = new Set(), anchor = null, sessionId = '', loading = false; -let refreshQueued = false; +let refreshQueued = false, syncing = false; function visibleTabs() { const query = byId('search').value.trim().toLocaleLowerCase(); @@ -84,6 +84,7 @@ async function bridge(reconnect = false) { async function refresh() { if (loading) { refreshQueued = true; return; } + const preserveNotice = syncing; loading = true; byId('refresh').disabled = true; try { @@ -95,7 +96,7 @@ async function refresh() { selected = retainSelection(selected, tabs); if (!tabs.some(tab => tab.tabId === anchor)) anchor = null; if (changed) render(); - } catch { byId('notice').textContent = '无法读取标签列表,请重新加载扩展后刷新本页。'; } + } catch { if (!preserveNotice && !syncing) byId('notice').textContent = '无法读取标签列表,请重新加载扩展后刷新本页。'; } finally { loading = false; byId('refresh').disabled = false; if (refreshQueued) { refreshQueued = false; refresh(); } @@ -103,8 +104,28 @@ async function refresh() { await bridge(); } +async function syncOpenTabs() { + if (syncing) return; + syncing = true; + byId('sync').disabled = true; + byId('sync').textContent = '正在同步…'; + byId('notice').textContent = '正在读取已加载网页的原始标题…'; + try { + const result = await chrome.runtime.sendMessage({type: 'dia-tabs-sync'}); + if (!result?.ok) throw Error('sync failed'); + await refresh(); + byId('notice').textContent = `同步完成:成功 ${result.captured} 个,无法读取 ${result.unavailable} 个,跳过 ${result.skipped} 个。`; + } catch { byId('notice').textContent = '同步失败,请稍后重试。'; } + finally { + syncing = false; + byId('sync').disabled = false; + byId('sync').textContent = '同步已打开标签'; + } +} + byId('search').addEventListener('input', render); byId('refresh').addEventListener('click', refresh); +byId('sync').addEventListener('click', syncOpenTabs); byId('reconnect').addEventListener('click', async () => { await bridge(true); setTimeout(() => bridge(), 500); }); byId('copy-urls').addEventListener('click', () => copy(selectedOutput(visibleTabs(), selected, 'url'))); byId('copy-markdown').addEventListener('click', () => copy(selectedOutput(visibleTabs(), selected, 'markdown'))); diff --git a/plugins/dia_tabs/extension/state.js b/plugins/dia_tabs/extension/state.js index 768b3ce..e5cdf38 100644 --- a/plugins/dia_tabs/extension/state.js +++ b/plugins/dia_tabs/extension/state.js @@ -26,7 +26,8 @@ export function readPageTitle() { // every asynchronous result is checked against both navigation and sample IDs. export function createTabService(api) { const records = new Map(), versions = new Map(), samples = new Map(), tasks = new Set(), initialRemovals = new Set(); - let sessionId, initialized = false, writeTail = Promise.resolve(); + const pendingManual = new Set(); + let sessionId, initialized = false, writeTail = Promise.resolve(), syncPromise = null; const persist = () => { writeTail = writeTail.catch(() => {}).then(() => api.storage.session.set({ diaTabsSession: sessionId, diaTabsRecords: Object.fromEntries(records), @@ -45,23 +46,42 @@ export function createTabService(api) { else records.delete(id); if (sessionId) persist(); }; - const capture = async tab => { - if (!isWeb(tab) || !tab.active || tab.status !== 'complete') return; + const capture = async (tab, {allowBackground = false} = {}) => { + if (!isWeb(tab) || (!allowBackground && !tab.active) || tab.status !== 'complete' || tab.discarded || tab.frozen) return 'skipped'; const version = versions.get(tab.id) || 0, sample = (samples.get(tab.id) || 0) + 1; samples.set(tab.id, sample); - let page; + // A queued manual sample must not inject into a tab discarded/frozen since + // the batch snapshot. No activation, reload or other wake-up is attempted. + if (allowBackground) { + let current; + try { current = await api.tabs.get(tab.id); } catch { return 'skipped'; } + if (!isWeb(current) || current.status !== 'complete' || current.discarded || current.frozen || current.url !== tab.url + || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return 'skipped'; + } + let page, timeout; try { - const results = await api.scripting.executeScript({target: {tabId: tab.id, frameIds: [0]}, func: readPageTitle}); + if (allowBackground && pendingManual.size >= 4) return 'skipped'; + const execution = api.scripting.executeScript({target: {tabId: tab.id, frameIds: [0]}, func: readPageTitle}); + if (allowBackground) { + pendingManual.add(execution); + execution.then(() => pendingManual.delete(execution), () => pendingManual.delete(execution)); + } + const results = allowBackground ? await Promise.race([execution, new Promise(resolve => { + timeout = setTimeout(() => resolve(null), 5000); + })]) : await execution; + if (results === null) return 'skipped'; page = results.find(result => result.frameId === 0)?.result; } catch { /* Restricted pages remain visible with an explicit unavailable state. */ } + finally { clearTimeout(timeout); } let current; - try { current = await api.tabs.get(tab.id); } catch { return; } - if (!isWeb(current) || !current.active || current.status !== 'complete' || current.url !== tab.url - || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return; - if (page && (page.url !== tab.url || page.readyState !== 'complete')) return; + try { current = await api.tabs.get(tab.id); } catch { return 'skipped'; } + if (!isWeb(current) || (!allowBackground && !current.active) || current.status !== 'complete' || current.discarded || current.frozen || current.url !== tab.url + || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return 'skipped'; + if (page && (page.url !== tab.url || page.readyState !== 'complete')) return 'skipped'; records.set(tab.id, {url: tab.url, title: typeof page?.title === 'string' ? page.title : '', titleState: typeof page?.title === 'string' ? 'captured' : 'unavailable'}); persist(); + return records.get(tab.id).titleState; }; const ready = (async () => { const saved = await api.storage.session.get(['diaTabsSession', 'diaTabsRecords']); @@ -79,7 +99,7 @@ export function createTabService(api) { initialized = true; initialRemovals.clear(); persist(); - await Promise.all(tabs.map(capture)); + await Promise.all(tabs.map(tab => capture(tab))); })(); const refreshTab = id => track(ready.then(async () => { try { const tab = await api.tabs.get(id); await capture(tab); } catch { /* Tab closed. */ } @@ -101,7 +121,7 @@ export function createTabService(api) { const tabs = (await api.tabs.query({})).filter(isWeb).sort((a,b) => a.windowId-b.windowId || a.index-b.index); const live = new Set(tabs.map(tab => tab.id)); for (const id of records.keys()) if (!live.has(id)) records.delete(id); - await Promise.all(tabs.filter(tab => !records.has(tab.id) || records.get(tab.id).url !== tab.url || records.get(tab.id).titleState === 'pending').map(capture)); + await Promise.all(tabs.filter(tab => !records.has(tab.id) || records.get(tab.id).url !== tab.url || records.get(tab.id).titleState === 'pending').map(tab => capture(tab))); persist(); return tabs.map(tab => { const record = records.get(tab.id); @@ -110,6 +130,24 @@ export function createTabService(api) { title: valid ? record.title : '', titleState: valid ? record.titleState : 'pending', active: tab.active}; }); }; + const syncOpenTabs = () => { + if (syncPromise) return syncPromise; + syncPromise = (async () => { + await ready; + const tabs = (await api.tabs.query({})).filter(isWeb); + const counts = {captured: 0, unavailable: 0, skipped: 0}; + let next = 0; + await Promise.all(Array.from({length: Math.min(4, tabs.length)}, async () => { + while (next < tabs.length) { + const result = await capture(tabs[next++], {allowBackground: true}); + counts[result]++; + } + })); + return counts; + })().finally(() => { syncPromise = null; }); + track(syncPromise); + return syncPromise; + }; const handleRequest = async request => { const response = {v: 1, id: typeof request?.id === 'string' ? request.id : '', ok: false}; const fail = (error, message) => ({...response, error, message}); @@ -134,7 +172,7 @@ export function createTabService(api) { return {...response, ok: true}; } catch { return fail('INTERNAL_ERROR', '浏览器操作失败,请刷新后重试'); } }; - return {ready, list, handleRequest, refreshTab, get sessionId() {return sessionId;}, async idle() { + return {ready, list, syncOpenTabs, handleRequest, refreshTab, get sessionId() {return sessionId;}, async idle() { await ready; while (tasks.size) await Promise.allSettled([...tasks]); await writeTail; diff --git a/plugins/dia_tabs/tests/background.test.mjs b/plugins/dia_tabs/tests/background.test.mjs index aa78b9e..3591651 100644 --- a/plugins/dia_tabs/tests/background.test.mjs +++ b/plugins/dia_tabs/tests/background.test.mjs @@ -123,3 +123,102 @@ test('close during initial query cannot reinsert a stale restored record', async release([tab(1,{active:false})]);await s.ready;await s.idle(); assert.equal(f.storage.diaTabsRecords[1],undefined); }); + +test('explicit sync reads complete background pages without activating or waking skipped pages', async () => { + const f=fake([tab(1,{active:false}),tab(2,{active:false}),tab(3,{active:false,status:'loading'}), + tab(4,{active:false,discarded:true}),tab(5,{active:false,frozen:true}),tab(6,{incognito:true}), + tab(7,{url:'chrome://settings/'}),tab(8,{active:false})]); + const s=service(f.api);await s.ready; + assert.deepEqual(f.calls,[]); + f.api.tabs.reload=async()=>{throw Error('sync must not reload');}; + f.api.tabs.create=async()=>{throw Error('sync must not open tabs');}; + const execute=f.api.scripting.executeScript; + f.api.scripting.executeScript=async options=>{if(options.target.tabId===8)throw Error('restricted');return execute(options);}; + assert.equal(typeof s.syncOpenTabs,'function'); + assert.deepEqual(await s.syncOpenTabs(),{captured:2,unavailable:1,skipped:3}); + const rows=await s.list(); + assert.equal(rows.find(row=>row.tabId===1).title,'DOM 1'); + assert.equal(rows.find(row=>row.tabId===8).titleState,'unavailable'); + assert.deepEqual(f.calls.filter(call=>call[0]==='script').map(call=>call[1]).sort(),[1,2]); + assert.ok(f.calls.every(call=>call[0]==='script')); + f.api.tabs.onUpdated.emit(2,{title:'Renamed browser label'},f.current.get(2));await s.idle(); + assert.equal(f.calls.filter(call=>call[0]==='script').length,2); + assert.equal((await s.handleRequest({v:1,id:'native-sync',op:'sync'})).error,'BAD_REQUEST'); +}); + +test('sync shares one in-flight batch and never exceeds four simultaneous samples', async () => { + const f=fake(Array.from({length:9},(_,index)=>tab(index+1,{active:false}))); + const s=service(f.api);await s.ready; + const waiting=[];let running=0,maximum=0,total=0; + f.api.scripting.executeScript=({target})=>new Promise(resolve=>{ + running++;total++;maximum=Math.max(maximum,running); + waiting.push(()=>{running--;resolve([{frameId:0,result:{title:`Synced ${target.tabId}`,url:'https://example.com/',readyState:'complete'}}]);}); + }); + assert.equal(typeof s.syncOpenTabs,'function'); + const first=s.syncOpenTabs(),second=s.syncOpenTabs();assert.equal(first,second); + await new Promise(resolve=>setImmediate(resolve));assert.equal(running,4); + while(total<9 || waiting.length) { + for(const release of waiting.splice(0)) release(); + await new Promise(resolve=>setImmediate(resolve)); + } + assert.deepEqual(await first,{captured:9,unavailable:0,skipped:0}); + assert.equal(maximum,4);assert.equal(total,9); +}); + +test('manual background results are rejected after navigation, close or a later automatic sample', async t => { + for(const change of ['navigation','close','newer']) await t.test(change,async()=>{ + const f=fake([tab(1,{active:false})]);const s=service(f.api);await s.ready;let release; + f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); + assert.equal(typeof s.syncOpenTabs,'function');const pending=s.syncOpenTabs(); + await new Promise(resolve=>setImmediate(resolve)); + if(change==='navigation') { + f.current.set(1,tab(1,{active:false,status:'loading'}));f.api.tabs.onUpdated.emit(1,{status:'loading'},f.current.get(1)); + } else if(change==='close') {f.current.delete(1);f.api.tabs.onRemoved.emit(1);} + else { + f.api.scripting.executeScript=async()=>[{frameId:0,result:{title:'Newer DOM',url:'https://example.com/',readyState:'complete'}}]; + f.current.set(1,tab(1));f.api.tabs.onUpdated.emit(1,{title:'browser label'},f.current.get(1)); + await new Promise(resolve=>setImmediate(resolve)); + } + release([{frameId:0,result:{title:'Stale manual title',url:'https://example.com/',readyState:'complete'}}]); + assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1});await s.idle(); + assert.equal(f.storage.diaTabsRecords[1]?.title,change==='close'?undefined:change==='newer'?'Newer DOM':''); + }); +}); + +test('sync rechecks discarded/frozen before injection and rejects a page frozen during sampling', async () => { + const f=fake([tab(1,{active:false}),tab(2,{active:false})]);const s=service(f.api);await s.ready; + const query=f.api.tabs.query; + f.api.tabs.query=async()=>{const snapshot=await query();f.current.set(1,tab(1,{active:false,discarded:true}));return snapshot;}; + let scripts=0; + f.api.scripting.executeScript=async({target})=>{ + scripts++;f.current.set(target.tabId,tab(target.tabId,{active:false,frozen:true})); + return [{frameId:0,result:{title:'Frozen result',url:'https://example.com/',readyState:'complete'}}]; + }; + assert.equal(typeof s.syncOpenTabs,'function'); + assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:2});assert.equal(scripts,1); +}); + +test('a stalled manual script times out as skipped and late execution cannot restore its result', async t => { + const f=fake([tab(1,{active:false})]);const s=service(f.api);await s.ready; + let release;f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); + t.mock.timers.enable({apis:['setTimeout']}); + assert.equal(typeof s.syncOpenTabs,'function');const pending=s.syncOpenTabs(); + await new Promise(resolve=>setImmediate(resolve));t.mock.timers.tick(5000); + assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1}); + release([{frameId:0,result:{title:'Too late',url:'https://example.com/',readyState:'complete'}}]); + await new Promise(resolve=>setImmediate(resolve));await s.idle(); + assert.equal(f.storage.diaTabsRecords[1]?.title,''); +}); + +test('timed-out scripts keep the four execution slots across repeated sync batches', async t => { + const f=fake(Array.from({length:9},(_,index)=>tab(index+1,{active:false}))); + const s=service(f.api);await s.ready;const release=[];let calls=0; + f.api.scripting.executeScript=()=>{calls++;return new Promise(resolve=>release.push(resolve));}; + t.mock.timers.enable({apis:['setTimeout']}); + const first=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,4); + t.mock.timers.tick(5000);await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,4); + assert.deepEqual(await first,{captured:0,unavailable:0,skipped:9}); + assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:9});assert.equal(calls,4); + for(const resolve of release)resolve([{frameId:0,result:{title:'Late',url:'https://example.com/',readyState:'complete'}}]); + await new Promise(resolve=>setImmediate(resolve)); +}); diff --git a/plugins/dia_tabs/tests/bridge.test.mjs b/plugins/dia_tabs/tests/bridge.test.mjs index 364a10d..cb2f8aa 100644 --- a/plugins/dia_tabs/tests/bridge.test.mjs +++ b/plugins/dia_tabs/tests/bridge.test.mjs @@ -17,7 +17,7 @@ function harness(unavailable=false) { action:{onClicked:event('action')}, }; runInNewContext(source,{chrome:api,createTabService:()=>service}); - return {api,events,ports,alarms,notices}; + return {api,events,ports,alarms,notices,service}; } test('native bridge forwards protocol, retries only on minute alarm and reports disconnection',async()=>{ @@ -31,6 +31,20 @@ test('native bridge forwards protocol, retries only on minute alarm and reports f.events.alarm({name:'dia-tabs-reconnect'});assert.equal(f.ports.length,2); }); +test('only trusted extension pages can sync; errors reply and native cannot call sync',async()=>{ + const f=harness();let calls=0,response; + f.service.syncOpenTabs=async()=>{calls++;return {captured:2,unavailable:1,skipped:3};}; + const control={id:'extension-id',url:'chrome-extension://extension-id/control.html'}; + assert.equal(f.events.message({type:'dia-tabs-sync'},control,value=>response=value),true); + await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,1);assert.equal(response.captured,2); + response=undefined; + assert.equal(f.events.message({type:'dia-tabs-sync'},{id:'extension-id',url:'https://example.com/',tab:{id:7},frameId:0},()=>{}),false); + f.ports[0].receive({v:1,id:'native',type:'dia-tabs-sync'});await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,1); + f.service.syncOpenTabs=async()=>{throw Error('private diagnostic');}; + f.events.message({type:'dia-tabs-sync'},control,value=>response=value);await new Promise(resolve=>setImmediate(resolve)); + assert.equal(response.ok,false);assert.equal(response.error,'INTERNAL_ERROR');assert.ok(!response.message.includes('private diagnostic')); +}); + test('control remains usable without native host and content/foreign messages cannot activate tabs',async()=>{ const f=harness(true);let response; const control={id:'extension-id',url:'chrome-extension://extension-id/control.html'}; diff --git a/plugins/dia_tabs/tests/control.test.mjs b/plugins/dia_tabs/tests/control.test.mjs index 72ce53b..aafddf6 100644 --- a/plugins/dia_tabs/tests/control.test.mjs +++ b/plugins/dia_tabs/tests/control.test.mjs @@ -45,7 +45,7 @@ test('control page prevents default URL navigation, preserves modified selection append(...children) {this.children.push(...children);} replaceChildren() {this.children=[];} } - const ids=['search','tabs','copy-urls','copy-markdown','select-all','empty','count','notice','refresh','reconnect','bridge-status']; + const ids=['search','tabs','copy-urls','copy-markdown','select-all','empty','count','notice','refresh','sync','reconnect','bridge-status']; const elements=Object.fromEntries(ids.map(id=>[id,new Element()])); const requests=[],copied=[]; const rows=[{tabId:11,windowId:1,index:0,url:'https://example.com/',title:'DOM title',titleState:'captured',active:false}]; @@ -109,3 +109,30 @@ test('refresh queues events during list and refreshes captured titles after sess events.updated(1,{title:'Unrelated UI title'});await new Promise(resolve=>setImmediate(resolve)); assert.equal(elements.tabs.renders,previousRenders); }); + +test('sync button shows counts, preserves selection, ignores repeat clicks and recovers after failure',async()=>{ + const {runInNewContext}=await import('node:vm'); + class Element { + constructor(){this.children=[];this.listeners={};this.value='';this.classList={toggle(){}};} + addEventListener(type,fn){this.listeners[type]=fn;}setAttribute(){}append(...items){this.children.push(...items);}replaceChildren(){this.children=[];} + } + const elements={},event={addListener(){}};let resolveSync,syncCalls=0,fail=false; + let rows=[{tabId:1,windowId:1,index:0,url:'https://example.com/',title:'Old DOM',titleState:'captured',active:false}]; + const context={...core,crypto:{randomUUID:()=> 'request'},setTimeout,window:{addEventListener(){}}, + document:{getElementById:id=>elements[id] ||= new Element(),createElement:()=>new Element()},navigator:{clipboard:{async writeText(){}}}, + chrome:{runtime:{async sendMessage(request){if(request.type==='dia-tabs-sync'){syncCalls++;if(fail)throw Error('bridge gone');return new Promise(resolve=>{resolveSync=resolve;});}if(request.op==='list')return {ok:true,sessionId:'s',tabs:rows};return {state:'connected'};}}, + tabs:{onCreated:event,onRemoved:event,onUpdated:event,onActivated:event},storage:{onChanged:event}}, + }; + const ui=(await readFile(new URL('../extension/control.js',import.meta.url),'utf8')).replace(/^import .*;\n/,''); + runInNewContext(ui,context);await new Promise(resolve=>setImmediate(resolve)); + elements.tabs.children[0].listeners.click({}); + assert.equal(typeof elements.sync?.listeners.click,'function'); + const pending=elements.sync.listeners.click();assert.equal(elements.sync.disabled,true);assert.equal(elements.sync.textContent,'正在同步…'); + elements.sync.listeners.click();assert.equal(syncCalls,1); + rows=[{...rows[0],title:'New DOM'}];resolveSync({ok:true,captured:1,unavailable:2,skipped:3});await pending; + assert.equal(elements.sync.disabled,false);assert.equal(elements.sync.textContent,'同步已打开标签'); + assert.equal(elements.tabs.children[0].children[0].children[0].checked,true); + assert.equal(elements.tabs.children[0].children[1].children[0].textContent,'New DOM'); + assert.match(elements.notice.textContent,/成功 1.*无法读取 2.*跳过 3/); + fail=true;await elements.sync.listeners.click();assert.equal(elements.sync.disabled,false);assert.match(elements.notice.textContent,/同步失败/); +}); From 07c00dbac2e1eeb547de7842fec184fd87d28231 Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:31:37 +0800 Subject: [PATCH 4/9] fix: explain Dia tab sync skips and verify background capture --- docs/plugins/dia_tabs.md | 4 ++- docs/specs/2026-11-01-dia-tabs-design.md | 4 +-- plugins/dia_tabs/extension/control.js | 7 ++-- plugins/dia_tabs/extension/state.js | 39 ++++++++++++++-------- plugins/dia_tabs/tests/background.test.mjs | 25 ++++++++++---- plugins/dia_tabs/tests/control.test.mjs | 13 ++++++-- plugins/dia_tabs/tests/fixtures/alpha.html | 14 ++++++++ 7 files changed, 78 insertions(+), 28 deletions(-) diff --git a/docs/plugins/dia_tabs.md b/docs/plugins/dia_tabs.md index a7cd0e3..d5706b4 100644 --- a/docs/plugins/dia_tabs.md +++ b/docs/plugins/dia_tabs.md @@ -76,6 +76,8 @@ plugins/dia_tabs/install.sh \ 仅列出 HTTP/HTTPS 网页。不支持隐私窗口,不读取 `file://`、`chrome://`、`dia://` 或扩展页面。Markdown 转义反斜线和方括号,标题换行归为空格;URL 中的空格、控制字符、括号、尖括号和反斜线做百分号编码,保留已有百分号编码。 +同步完成后显示本轮跳过原因及数量:已释放、已冻结、加载中、尚未加载、状态未知、页面已变化、已关闭、读取超时或并发额度不足。列表状态列显示浏览器当前状态;已记录标题可与“已释放”等当前状态同时显示。浏览器没有提供的状态显示为未知,不推断为加载完成。诊断只用于当前展示,不单独保存或记录网页 URL。 + ## 本机桥接与排障 每个扩展 native 连接创建一个随机 socket,目录权限 `0700`、socket 权限 `0600`。host 只转发当前请求,不保存网页快照;标准输入断开即移除自身 socket。崩溃遗留的 socket 不会被当作历史数据展示。最多并发 32 个请求,单消息上限 1 MiB,默认 host 响应超时 2 秒;Alfred 并行查询连接,忽略不可用连接。 @@ -125,7 +127,7 @@ Python 集成测试需要在临时目录创建 Unix socket,不操作真实浏 |---|---| | Native 桥接与 Alfred 列表 | Chrome 用户目录注册成功;Alfred 搜索可以显示演示网页 | | DOM 标题与控制页 | 动态标题由 Alpha 更新为包含方括号和中文的标题;点击 URL 返回同一标签;所选单项 Markdown 在真实文本框粘贴准确;关闭演示标签后列表由 1 项变为 0 项 | -| 手动同步 | 同步按钮、完成计数和 Alfred 共用缓存已实测;加载完成的后台页成功采集及各跳过状态的实机覆盖待验证 | +| 手动同步 | 测试页转到后台后修改 DOM title,控制页先保持旧标题;点击同步后更新为新标题,测试页未被激活、标签数量未增加。已释放、已冻结页面的跳过原因和数量已实测,Alfred 共用缓存数量一致 | | 待验证 | 手动重命名后动态标题、跨窗口/最小化、浏览器退出后的清理、Alfred 回车实际切换及 Cmd/Alt+回车实际复制 | Alfred 修饰键复制已通过参数和 `pbcopy` 的离线测试;组合键的实机交互尚未确认,不能据此声称验收完成。 diff --git a/docs/specs/2026-11-01-dia-tabs-design.md b/docs/specs/2026-11-01-dia-tabs-design.md index f5c9ed4..5819f2c 100644 --- a/docs/specs/2026-11-01-dia-tabs-design.md +++ b/docs/specs/2026-11-01-dia-tabs-design.md @@ -64,7 +64,7 @@ 采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。显式手动同步仅放宽 active 门槛,并在注入前再次读取实时标签状态;导航、关闭和更晚采样校验不变。导航事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 -手动同步最多并发 4 个实际未结束的脚本读取,重复请求复用同一批次。读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,额度不足时其余页面计为跳过,防止超时累积无限注入。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。 +手动同步最多并发 4 个实际未结束的脚本读取,重复请求复用同一批次。读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,额度不足时其余页面计为跳过,防止超时累积无限注入。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。同步响应另含 `skipReasons` 原因计数:`discarded`、`frozen`、`loading`、`unloaded`、`unknown_status`、`stale`、`closed`、`timeout`、`capacity`;已释放、冻结优先于 status 判定。仅返回本轮汇总,不持久化诊断。 service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录。不能只依赖 `onStartup`:休眠后重启 worker 同样要初始化。 @@ -87,7 +87,7 @@ Native host 名称固定为 `com.mcoder2014.life_tools.dia_tabs`。协议使用 {"v":1,"id":"request-uuid","ok":false,"error":"TAB_CLOSED"} ``` -`tabs` 每项字段固定为 `{tabId:number, windowId:number, index:number, url:string, title:string, titleState:"captured"|"pending"|"unavailable", active:boolean}`。空 title 且 captured 表示无标题;pending 表示未采集。成功激活响应为 `{v:1,id,ok:true}`。失败允许 `TAB_CLOSED`、`STALE_SESSION`、`BAD_REQUEST`、`INTERNAL_ERROR`,可附 `message:string`;不得把内部 traceback 发给页面。native host 可以在连接/超时时补 `BRIDGE_UNAVAILABLE`、`TIMEOUT` 错误。 +`tabs` 每项字段固定为 `{tabId:number, windowId:number, index:number, url:string, title:string, titleState:"captured"|"pending"|"unavailable", active:boolean}`。列表另附实时 `status`(缺失为 `unknown`)、`discarded` 和 `frozen`(缺失为 `null`),用于控制页解释当前状态,不改变标题缓存。空 title 且 captured 表示无标题;pending 表示未采集。成功激活响应为 `{v:1,id,ok:true}`。失败允许 `TAB_CLOSED`、`STALE_SESSION`、`BAD_REQUEST`、`INTERNAL_ERROR`,可附 `message:string`;不得把内部 traceback 发给页面。native host 可以在连接/超时时补 `BRIDGE_UNAVAILABLE`、`TIMEOUT` 错误。 Unix socket 一次连接一个请求,使用换行分隔 JSON,设置读取超时与消息上限。native stdio 按 Chrome 协议使用 4 字节本机字节序长度 + UTF-8 JSON;stdout 只能写协议,诊断走 stderr,不记录真实 title/URL。host 为并发客户端分配内部请求 ID,不能直接信任客户端 id 的全局唯一性。超时或断线必须解除等待,不遗留 pending 请求。 diff --git a/plugins/dia_tabs/extension/control.js b/plugins/dia_tabs/extension/control.js index 260af02..41f6e35 100644 --- a/plugins/dia_tabs/extension/control.js +++ b/plugins/dia_tabs/extension/control.js @@ -63,7 +63,8 @@ function render() { link.addEventListener('auxclick', event => { event.preventDefault(); event.stopPropagation(); }); urlCell.append(link); const stateCell = document.createElement('td'); stateCell.className = 'state'; - stateCell.textContent = `${tab.active ? '当前标签 · ' : ''}${tab.titleState === 'captured' ? '已记录' : tab.titleState === 'pending' ? '待访问' : '无法读取'} · 窗口 ${tab.windowId}`; + const pageState = tab.discarded ? '已释放' : tab.frozen ? '已冻结' : tab.status === 'loading' ? '加载中' : tab.status === 'unloaded' ? '尚未加载' : tab.status === 'complete' ? '已加载' : '状态未知'; + stateCell.textContent = `${tab.active ? '当前标签 · ' : ''}${tab.titleState === 'captured' ? '已记录' : tab.titleState === 'pending' ? '待访问' : '无法读取'} · ${pageState} · 窗口 ${tab.windowId}`; const copyCell = document.createElement('td'); const copyButton = document.createElement('button'); copyButton.type = 'button'; copyButton.className = 'copy-button'; copyButton.textContent = '复制 URL'; copyButton.addEventListener('click', event => { event.stopPropagation(); copy(tab.url); }); copyCell.append(copyButton); @@ -114,7 +115,9 @@ async function syncOpenTabs() { const result = await chrome.runtime.sendMessage({type: 'dia-tabs-sync'}); if (!result?.ok) throw Error('sync failed'); await refresh(); - byId('notice').textContent = `同步完成:成功 ${result.captured} 个,无法读取 ${result.unavailable} 个,跳过 ${result.skipped} 个。`; + const labels = {discarded: '已释放', frozen: '已冻结', loading: '加载中', unloaded: '尚未加载', unknown_status: '状态未知', stale: '页面已变化', closed: '已关闭', timeout: '读取超时', capacity: '先前读取尚未结束'}; + const reasons = Object.entries(result.skipReasons || {}).map(([reason, count]) => `${labels[reason] || '其他'} ${count} 个`).join(','); + byId('notice').textContent = `同步完成:成功 ${result.captured} 个,无法读取 ${result.unavailable} 个,跳过 ${result.skipped} 个${reasons ? `(${reasons})` : ''}。`; } catch { byId('notice').textContent = '同步失败,请稍后重试。'; } finally { syncing = false; diff --git a/plugins/dia_tabs/extension/state.js b/plugins/dia_tabs/extension/state.js index e5cdf38..5294f76 100644 --- a/plugins/dia_tabs/extension/state.js +++ b/plugins/dia_tabs/extension/state.js @@ -47,20 +47,26 @@ export function createTabService(api) { if (sessionId) persist(); }; const capture = async (tab, {allowBackground = false} = {}) => { - if (!isWeb(tab) || (!allowBackground && !tab.active) || tab.status !== 'complete' || tab.discarded || tab.frozen) return 'skipped'; + if (!isWeb(tab)) return 'unsupported'; + if (!allowBackground && !tab.active) return 'inactive'; + if (tab.discarded) return 'discarded'; + if (tab.frozen) return 'frozen'; + if (tab.status !== 'complete') return tab.status === 'loading' ? 'loading' : tab.status === 'unloaded' ? 'unloaded' : 'unknown_status'; const version = versions.get(tab.id) || 0, sample = (samples.get(tab.id) || 0) + 1; samples.set(tab.id, sample); // A queued manual sample must not inject into a tab discarded/frozen since // the batch snapshot. No activation, reload or other wake-up is attempted. if (allowBackground) { let current; - try { current = await api.tabs.get(tab.id); } catch { return 'skipped'; } - if (!isWeb(current) || current.status !== 'complete' || current.discarded || current.frozen || current.url !== tab.url - || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return 'skipped'; + try { current = await api.tabs.get(tab.id); } catch { return 'closed'; } + if (current.discarded) return 'discarded'; + if (current.frozen) return 'frozen'; + if (!isWeb(current) || current.url !== tab.url || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return 'stale'; + if (current.status !== 'complete') return current.status === 'loading' ? 'loading' : current.status === 'unloaded' ? 'unloaded' : 'unknown_status'; } let page, timeout; try { - if (allowBackground && pendingManual.size >= 4) return 'skipped'; + if (allowBackground && pendingManual.size >= 4) return 'capacity'; const execution = api.scripting.executeScript({target: {tabId: tab.id, frameIds: [0]}, func: readPageTitle}); if (allowBackground) { pendingManual.add(execution); @@ -69,15 +75,18 @@ export function createTabService(api) { const results = allowBackground ? await Promise.race([execution, new Promise(resolve => { timeout = setTimeout(() => resolve(null), 5000); })]) : await execution; - if (results === null) return 'skipped'; + if (results === null) return 'timeout'; page = results.find(result => result.frameId === 0)?.result; } catch { /* Restricted pages remain visible with an explicit unavailable state. */ } finally { clearTimeout(timeout); } let current; - try { current = await api.tabs.get(tab.id); } catch { return 'skipped'; } - if (!isWeb(current) || (!allowBackground && !current.active) || current.status !== 'complete' || current.discarded || current.frozen || current.url !== tab.url - || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return 'skipped'; - if (page && (page.url !== tab.url || page.readyState !== 'complete')) return 'skipped'; + try { current = await api.tabs.get(tab.id); } catch { return 'closed'; } + if (current.discarded) return 'discarded'; + if (current.frozen) return 'frozen'; + if (!isWeb(current) || current.url !== tab.url || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return 'stale'; + if (!allowBackground && !current.active) return 'inactive'; + if (current.status !== 'complete') return current.status === 'loading' ? 'loading' : current.status === 'unloaded' ? 'unloaded' : 'unknown_status'; + if (page && (page.url !== tab.url || page.readyState !== 'complete')) return 'stale'; records.set(tab.id, {url: tab.url, title: typeof page?.title === 'string' ? page.title : '', titleState: typeof page?.title === 'string' ? 'captured' : 'unavailable'}); persist(); @@ -127,7 +136,10 @@ export function createTabService(api) { const record = records.get(tab.id); const valid = record?.url === tab.url; return {tabId: tab.id, windowId: tab.windowId, index: tab.index, url: tab.url, - title: valid ? record.title : '', titleState: valid ? record.titleState : 'pending', active: tab.active}; + title: valid ? record.title : '', titleState: valid ? record.titleState : 'pending', active: tab.active, + status: typeof tab.status === 'string' ? tab.status : 'unknown', + discarded: typeof tab.discarded === 'boolean' ? tab.discarded : null, + frozen: typeof tab.frozen === 'boolean' ? tab.frozen : null}; }); }; const syncOpenTabs = () => { @@ -135,12 +147,13 @@ export function createTabService(api) { syncPromise = (async () => { await ready; const tabs = (await api.tabs.query({})).filter(isWeb); - const counts = {captured: 0, unavailable: 0, skipped: 0}; + const counts = {captured: 0, unavailable: 0, skipped: 0, skipReasons: {}}; let next = 0; await Promise.all(Array.from({length: Math.min(4, tabs.length)}, async () => { while (next < tabs.length) { const result = await capture(tabs[next++], {allowBackground: true}); - counts[result]++; + if (result === 'captured' || result === 'unavailable') counts[result]++; + else { counts.skipped++; counts.skipReasons[result] = (counts.skipReasons[result] || 0) + 1; } } })); return counts; diff --git a/plugins/dia_tabs/tests/background.test.mjs b/plugins/dia_tabs/tests/background.test.mjs index 3591651..48fd35f 100644 --- a/plugins/dia_tabs/tests/background.test.mjs +++ b/plugins/dia_tabs/tests/background.test.mjs @@ -135,7 +135,7 @@ test('explicit sync reads complete background pages without activating or waking const execute=f.api.scripting.executeScript; f.api.scripting.executeScript=async options=>{if(options.target.tabId===8)throw Error('restricted');return execute(options);}; assert.equal(typeof s.syncOpenTabs,'function'); - assert.deepEqual(await s.syncOpenTabs(),{captured:2,unavailable:1,skipped:3}); + assert.deepEqual(await s.syncOpenTabs(),{captured:2,unavailable:1,skipped:3,skipReasons:{loading:1,discarded:1,frozen:1}}); const rows=await s.list(); assert.equal(rows.find(row=>row.tabId===1).title,'DOM 1'); assert.equal(rows.find(row=>row.tabId===8).titleState,'unavailable'); @@ -161,7 +161,7 @@ test('sync shares one in-flight batch and never exceeds four simultaneous sample for(const release of waiting.splice(0)) release(); await new Promise(resolve=>setImmediate(resolve)); } - assert.deepEqual(await first,{captured:9,unavailable:0,skipped:0}); + assert.deepEqual(await first,{captured:9,unavailable:0,skipped:0,skipReasons:{}}); assert.equal(maximum,4);assert.equal(total,9); }); @@ -180,7 +180,7 @@ test('manual background results are rejected after navigation, close or a later await new Promise(resolve=>setImmediate(resolve)); } release([{frameId:0,result:{title:'Stale manual title',url:'https://example.com/',readyState:'complete'}}]); - assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1});await s.idle(); + assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1,skipReasons:{[change==='close'?'closed':'stale']:1}});await s.idle(); assert.equal(f.storage.diaTabsRecords[1]?.title,change==='close'?undefined:change==='newer'?'Newer DOM':''); }); }); @@ -195,7 +195,7 @@ test('sync rechecks discarded/frozen before injection and rejects a page frozen return [{frameId:0,result:{title:'Frozen result',url:'https://example.com/',readyState:'complete'}}]; }; assert.equal(typeof s.syncOpenTabs,'function'); - assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:2});assert.equal(scripts,1); + assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:2,skipReasons:{discarded:1,frozen:1}});assert.equal(scripts,1); }); test('a stalled manual script times out as skipped and late execution cannot restore its result', async t => { @@ -204,7 +204,7 @@ test('a stalled manual script times out as skipped and late execution cannot res t.mock.timers.enable({apis:['setTimeout']}); assert.equal(typeof s.syncOpenTabs,'function');const pending=s.syncOpenTabs(); await new Promise(resolve=>setImmediate(resolve));t.mock.timers.tick(5000); - assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1}); + assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1,skipReasons:{timeout:1}}); release([{frameId:0,result:{title:'Too late',url:'https://example.com/',readyState:'complete'}}]); await new Promise(resolve=>setImmediate(resolve));await s.idle(); assert.equal(f.storage.diaTabsRecords[1]?.title,''); @@ -217,8 +217,19 @@ test('timed-out scripts keep the four execution slots across repeated sync batch t.mock.timers.enable({apis:['setTimeout']}); const first=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,4); t.mock.timers.tick(5000);await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,4); - assert.deepEqual(await first,{captured:0,unavailable:0,skipped:9}); - assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:9});assert.equal(calls,4); + assert.deepEqual(await first,{captured:0,unavailable:0,skipped:9,skipReasons:{timeout:4,capacity:5}}); + assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:9,skipReasons:{capacity:9}});assert.equal(calls,4); for(const resolve of release)resolve([{frameId:0,result:{title:'Late',url:'https://example.com/',readyState:'complete'}}]); await new Promise(resolve=>setImmediate(resolve)); }); + + +test('list exposes live diagnostic fields and sync prioritizes discarded over unloaded status', async () => { + const f=fake([tab(1,{active:false,status:'unloaded',discarded:true}),tab(2,{active:false,status:undefined}),tab(3,{active:false,status:'loading'}),tab(4,{active:false,frozen:true}),tab(5,{active:false,status:'unloaded'})]); + const s=service(f.api);await s.ready; + const rows=await s.list(); + assert.equal(rows[0].status,'unloaded');assert.equal(rows[0].discarded,true); + assert.equal(rows[1].status,'unknown');assert.equal(rows[1].discarded,null);assert.equal(rows[1].frozen,null); + assert.deepEqual((await s.syncOpenTabs()).skipReasons,{discarded:1,unknown_status:1,loading:1,frozen:1,unloaded:1}); + assert.equal(f.calls.length,0); +}); diff --git a/plugins/dia_tabs/tests/control.test.mjs b/plugins/dia_tabs/tests/control.test.mjs index aafddf6..a74813a 100644 --- a/plugins/dia_tabs/tests/control.test.mjs +++ b/plugins/dia_tabs/tests/control.test.mjs @@ -117,7 +117,7 @@ test('sync button shows counts, preserves selection, ignores repeat clicks and r addEventListener(type,fn){this.listeners[type]=fn;}setAttribute(){}append(...items){this.children.push(...items);}replaceChildren(){this.children=[];} } const elements={},event={addListener(){}};let resolveSync,syncCalls=0,fail=false; - let rows=[{tabId:1,windowId:1,index:0,url:'https://example.com/',title:'Old DOM',titleState:'captured',active:false}]; + let rows=[{tabId:1,windowId:1,index:0,url:'https://example.com/',title:'Old DOM',titleState:'captured',active:false,status:'unloaded',discarded:true,frozen:false}]; const context={...core,crypto:{randomUUID:()=> 'request'},setTimeout,window:{addEventListener(){}}, document:{getElementById:id=>elements[id] ||= new Element(),createElement:()=>new Element()},navigator:{clipboard:{async writeText(){}}}, chrome:{runtime:{async sendMessage(request){if(request.type==='dia-tabs-sync'){syncCalls++;if(fail)throw Error('bridge gone');return new Promise(resolve=>{resolveSync=resolve;});}if(request.op==='list')return {ok:true,sessionId:'s',tabs:rows};return {state:'connected'};}}, @@ -125,14 +125,21 @@ test('sync button shows counts, preserves selection, ignores repeat clicks and r }; const ui=(await readFile(new URL('../extension/control.js',import.meta.url),'utf8')).replace(/^import .*;\n/,''); runInNewContext(ui,context);await new Promise(resolve=>setImmediate(resolve)); + assert.match(elements.tabs.children[0].children[3].textContent,/已记录.*已释放/); elements.tabs.children[0].listeners.click({}); assert.equal(typeof elements.sync?.listeners.click,'function'); const pending=elements.sync.listeners.click();assert.equal(elements.sync.disabled,true);assert.equal(elements.sync.textContent,'正在同步…'); elements.sync.listeners.click();assert.equal(syncCalls,1); - rows=[{...rows[0],title:'New DOM'}];resolveSync({ok:true,captured:1,unavailable:2,skipped:3});await pending; + rows=[{...rows[0],title:'New DOM'}];resolveSync({ok:true,captured:1,unavailable:2,skipped:3,skipReasons:{discarded:1,unknown_status:2}});await pending; assert.equal(elements.sync.disabled,false);assert.equal(elements.sync.textContent,'同步已打开标签'); assert.equal(elements.tabs.children[0].children[0].children[0].checked,true); assert.equal(elements.tabs.children[0].children[1].children[0].textContent,'New DOM'); - assert.match(elements.notice.textContent,/成功 1.*无法读取 2.*跳过 3/); + assert.match(elements.notice.textContent,/成功 1.*无法读取 2.*跳过 3.*已释放 1.*状态未知 2/); + for (const [status, frozen, expected] of [['complete',true,'已冻结'],['loading',false,'加载中'],['unloaded',false,'尚未加载'],['unknown',false,'状态未知']]) { + rows=[{...rows[0],discarded:false,frozen,status}]; + await elements.refresh.listeners.click(); + assert.ok(elements.tabs.children[0].children[3].textContent.includes(expected)); + assert.equal(elements.tabs.children[0].children[1].children[0].textContent,'New DOM'); + } fail=true;await elements.sync.listeners.click();assert.equal(elements.sync.disabled,false);assert.match(elements.notice.textContent,/同步失败/); }); diff --git a/plugins/dia_tabs/tests/fixtures/alpha.html b/plugins/dia_tabs/tests/fixtures/alpha.html index ca7e655..39f4550 100644 --- a/plugins/dia_tabs/tests/fixtures/alpha.html +++ b/plugins/dia_tabs/tests/fixtures/alpha.html @@ -5,11 +5,25 @@

Dia Tabs · Alpha 公开演示页

初始 document.title 为 Dia Tabs Demo Alpha。可在 Dia 手动重命名标签,随后测试扩展仍读取网页标题。

+ +

点击“离开本页时修改标题”后切到扩展控制页,离开约 1 秒后再点同步,标题应变为 Dia Tabs Background Sync Proof。

当前网页标题:Dia Tabs Demo Alpha

打开 Beta 新标签打开同 URL 的 Alpha 副本同 URL 重新加载

diff --git a/plugins/dia_tabs/extension/control.js b/plugins/dia_tabs/extension/control.js index 41f6e35..0f1c6e8 100644 --- a/plugins/dia_tabs/extension/control.js +++ b/plugins/dia_tabs/extension/control.js @@ -9,17 +9,22 @@ function visibleTabs() { return tabs.filter(tab => `${tab.title}\n${tab.url}`.toLocaleLowerCase().includes(query)); } +function notify(message, tone = 'info') { + byId('notice').textContent = message; + byId('notice').setAttribute('data-tone', tone); +} + async function copy(text) { try { await navigator.clipboard.writeText(text); - byId('notice').textContent = '已复制到剪贴板'; - } catch { byId('notice').textContent = '复制失败,请检查剪贴板权限后重试。'; } + notify('已复制到剪贴板', 'success'); + } catch { notify('复制失败,请检查剪贴板权限后重试。', 'error'); } } async function activate(tabId) { const response = await chrome.runtime.sendMessage({v:1, id:crypto.randomUUID(), op:'activate', sessionId, tabId}).catch(() => null); if (!response?.ok) { - byId('notice').textContent = response?.message || '无法切换标签,请刷新列表。'; + notify(response?.message || '无法切换标签,请刷新列表。', 'error'); if (response?.error === 'TAB_CLOSED' || response?.error === 'STALE_SESSION') await refresh(); } } @@ -50,6 +55,8 @@ function render() { const title = document.createElement('button'); title.className = 'title-button'; title.type = 'button'; title.textContent = tab.titleState === 'captured' ? (tab.title || '无标题') : tab.titleState === 'pending' ? '待访问' : '无法读取页面标题'; + title.title = title.textContent; + title.classList.toggle('is-placeholder', tab.titleState !== 'captured' || !tab.title); const navigate = event => { event.preventDefault(); if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; @@ -58,15 +65,29 @@ function render() { title.addEventListener('click', navigate); titleCell.append(title); const urlCell = document.createElement('td'); const link = document.createElement('a'); - link.className = 'url'; link.href = tab.url; link.textContent = tab.url; + link.className = 'url'; link.href = tab.url; link.textContent = tab.url; link.title = tab.url; link.addEventListener('click', navigate); link.addEventListener('auxclick', event => { event.preventDefault(); event.stopPropagation(); }); urlCell.append(link); - const stateCell = document.createElement('td'); stateCell.className = 'state'; - const pageState = tab.discarded ? '已释放' : tab.frozen ? '已冻结' : tab.status === 'loading' ? '加载中' : tab.status === 'unloaded' ? '尚未加载' : tab.status === 'complete' ? '已加载' : '状态未知'; - stateCell.textContent = `${tab.active ? '当前标签 · ' : ''}${tab.titleState === 'captured' ? '已记录' : tab.titleState === 'pending' ? '待访问' : '无法读取'} · ${pageState} · 窗口 ${tab.windowId}`; + const stateCell = document.createElement('td'); stateCell.className = 'state-cell'; + const primaryState = document.createElement('div'); primaryState.className = 'state-line'; + const recordState = document.createElement('span'); + const recordKind = tab.titleState === 'captured' ? 'captured' : tab.titleState === 'pending' ? 'pending' : 'unavailable'; + recordState.className = `state-badge ${recordKind}`; + recordState.textContent = recordKind === 'captured' ? '已记录' : recordKind === 'pending' ? '待访问' : '无法读取'; + primaryState.append(recordState); + if (tab.active) { + const current = document.createElement('span'); current.className = 'state-badge current'; current.textContent = '当前标签'; + primaryState.append(current); + } + const secondaryState = document.createElement('div'); secondaryState.className = 'state-line state-secondary'; + const pageState = tab.discarded ? {label:'已释放', kind:'discarded'} : tab.frozen ? {label:'已冻结', kind:'frozen'} : tab.status === 'loading' ? {label:'加载中', kind:'loading'} : tab.status === 'unloaded' ? {label:'尚未加载', kind:'unloaded'} : tab.status === 'complete' ? {label:'已加载', kind:'complete'} : {label:'状态未知', kind:'unknown'}; + const page = document.createElement('span'); page.className = `state-badge ${pageState.kind}`; page.textContent = pageState.label; + const windowLabel = document.createElement('span'); windowLabel.className = 'window-label'; windowLabel.textContent = `窗口 ${tab.windowId}`; + secondaryState.append(page, windowLabel); stateCell.append(primaryState, secondaryState); const copyCell = document.createElement('td'); - const copyButton = document.createElement('button'); copyButton.type = 'button'; copyButton.className = 'copy-button'; copyButton.textContent = '复制 URL'; + const copyButton = document.createElement('button'); copyButton.type = 'button'; copyButton.className = 'copy-button'; copyButton.textContent = '复制'; + copyButton.setAttribute('aria-label', `复制 ${tab.url}`); copyButton.addEventListener('click', event => { event.stopPropagation(); copy(tab.url); }); copyCell.append(copyButton); row.append(checkCell,titleCell,urlCell,stateCell,copyCell); body.append(row); } @@ -80,7 +101,12 @@ function render() { async function bridge(reconnect = false) { const result = await chrome.runtime.sendMessage({type:reconnect ? 'dia-tabs-reconnect' : 'dia-tabs-bridge-status'}).catch(() => null); - byId('bridge-status').textContent = result?.state === 'connected' ? '本机桥接已连接' : result?.state === 'connecting' ? '正在连接本机桥接…' : `本机桥接未连接${result?.message ? `:${result.message}` : ''}`; + const state = result?.state === 'connected' ? 'connected' : result?.state === 'connecting' ? 'connecting' : 'disconnected'; + byId('bridge-status').textContent = state === 'connected' ? 'Alfred 连接就绪' : state === 'connecting' ? '正在准备 Alfred 连接…' : 'Alfred 连接未就绪'; + byId('bridge-status').setAttribute('data-state', state); + const detail = state === 'disconnected' && result?.message ? result.message : ''; + byId('bridge-detail').textContent = detail; + byId('bridge-detail').hidden = !detail; } async function refresh() { @@ -97,7 +123,7 @@ async function refresh() { selected = retainSelection(selected, tabs); if (!tabs.some(tab => tab.tabId === anchor)) anchor = null; if (changed) render(); - } catch { if (!preserveNotice && !syncing) byId('notice').textContent = '无法读取标签列表,请重新加载扩展后刷新本页。'; } + } catch { if (!preserveNotice && !syncing) notify('无法读取标签列表,请重新加载扩展后刷新本页。', 'error'); } finally { loading = false; byId('refresh').disabled = false; if (refreshQueued) { refreshQueued = false; refresh(); } @@ -109,20 +135,22 @@ async function syncOpenTabs() { if (syncing) return; syncing = true; byId('sync').disabled = true; - byId('sync').textContent = '正在同步…'; - byId('notice').textContent = '正在读取已加载网页的原始标题…'; + byId('sync').setAttribute('aria-busy', 'true'); + byId('sync-label').textContent = '正在同步…'; + notify('正在读取已加载网页的原始标题…', 'progress'); try { const result = await chrome.runtime.sendMessage({type: 'dia-tabs-sync'}); if (!result?.ok) throw Error('sync failed'); await refresh(); const labels = {discarded: '已释放', frozen: '已冻结', loading: '加载中', unloaded: '尚未加载', unknown_status: '状态未知', stale: '页面已变化', closed: '已关闭', timeout: '读取超时', capacity: '先前读取尚未结束'}; const reasons = Object.entries(result.skipReasons || {}).map(([reason, count]) => `${labels[reason] || '其他'} ${count} 个`).join(','); - byId('notice').textContent = `同步完成:成功 ${result.captured} 个,无法读取 ${result.unavailable} 个,跳过 ${result.skipped} 个${reasons ? `(${reasons})` : ''}。`; - } catch { byId('notice').textContent = '同步失败,请稍后重试。'; } + notify(`同步完成:成功 ${result.captured} 个,无法读取 ${result.unavailable} 个,跳过 ${result.skipped} 个${reasons ? `(${reasons})` : ''}。`, 'success'); + } catch { notify('同步失败,请稍后重试。', 'error'); } finally { syncing = false; byId('sync').disabled = false; - byId('sync').textContent = '同步已打开标签'; + byId('sync').setAttribute('aria-busy', 'false'); + byId('sync-label').textContent = '同步已打开标签'; } } diff --git a/plugins/dia_tabs/tests/control.test.mjs b/plugins/dia_tabs/tests/control.test.mjs index a74813a..a5dd352 100644 --- a/plugins/dia_tabs/tests/control.test.mjs +++ b/plugins/dia_tabs/tests/control.test.mjs @@ -45,7 +45,7 @@ test('control page prevents default URL navigation, preserves modified selection append(...children) {this.children.push(...children);} replaceChildren() {this.children=[];} } - const ids=['search','tabs','copy-urls','copy-markdown','select-all','empty','count','notice','refresh','sync','reconnect','bridge-status']; + const ids=['search','tabs','copy-urls','copy-markdown','select-all','empty','count','notice','refresh','sync','reconnect','bridge-status','bridge-detail']; const elements=Object.fromEntries(ids.map(id=>[id,new Element()])); const requests=[],copied=[]; const rows=[{tabId:11,windowId:1,index:0,url:'https://example.com/',title:'DOM title',titleState:'captured',active:false}]; @@ -59,7 +59,10 @@ test('control page prevents default URL navigation, preserves modified selection const uiSource=(await readFile(new URL('../extension/control.js',import.meta.url),'utf8')).replace(/^import .*;\n/,''); runInNewContext(uiSource,context);await new Promise(resolve=>setImmediate(resolve)); const first=elements.tabs.children[0]; + const title=first.children[1].children[0]; const link=first.children[2].children[0]; + assert.equal(title.title,'DOM title'); + assert.equal(link.title,'https://example.com/'); const click={defaultPrevented:false,stopped:false,preventDefault(){this.defaultPrevented=true;},stopPropagation(){this.stopped=true;}}; link.listeners.click(click);await new Promise(resolve=>setImmediate(resolve)); assert.equal(click.defaultPrevented,true);assert.equal(click.stopped,true); @@ -116,6 +119,7 @@ test('sync button shows counts, preserves selection, ignores repeat clicks and r constructor(){this.children=[];this.listeners={};this.value='';this.classList={toggle(){}};} addEventListener(type,fn){this.listeners[type]=fn;}setAttribute(){}append(...items){this.children.push(...items);}replaceChildren(){this.children=[];} } + const textOf=element=>[element.textContent,...element.children.map(textOf)].filter(Boolean).join(' '); const elements={},event={addListener(){}};let resolveSync,syncCalls=0,fail=false; let rows=[{tabId:1,windowId:1,index:0,url:'https://example.com/',title:'Old DOM',titleState:'captured',active:false,status:'unloaded',discarded:true,frozen:false}]; const context={...core,crypto:{randomUUID:()=> 'request'},setTimeout,window:{addEventListener(){}}, @@ -125,21 +129,51 @@ test('sync button shows counts, preserves selection, ignores repeat clicks and r }; const ui=(await readFile(new URL('../extension/control.js',import.meta.url),'utf8')).replace(/^import .*;\n/,''); runInNewContext(ui,context);await new Promise(resolve=>setImmediate(resolve)); - assert.match(elements.tabs.children[0].children[3].textContent,/已记录.*已释放/); + assert.match(textOf(elements.tabs.children[0].children[3]),/已记录.*已释放/); elements.tabs.children[0].listeners.click({}); assert.equal(typeof elements.sync?.listeners.click,'function'); - const pending=elements.sync.listeners.click();assert.equal(elements.sync.disabled,true);assert.equal(elements.sync.textContent,'正在同步…'); + const pending=elements.sync.listeners.click();assert.equal(elements.sync.disabled,true);assert.equal(elements['sync-label'].textContent,'正在同步…'); elements.sync.listeners.click();assert.equal(syncCalls,1); rows=[{...rows[0],title:'New DOM'}];resolveSync({ok:true,captured:1,unavailable:2,skipped:3,skipReasons:{discarded:1,unknown_status:2}});await pending; - assert.equal(elements.sync.disabled,false);assert.equal(elements.sync.textContent,'同步已打开标签'); + assert.equal(elements.sync.disabled,false);assert.equal(elements['sync-label'].textContent,'同步已打开标签'); assert.equal(elements.tabs.children[0].children[0].children[0].checked,true); assert.equal(elements.tabs.children[0].children[1].children[0].textContent,'New DOM'); assert.match(elements.notice.textContent,/成功 1.*无法读取 2.*跳过 3.*已释放 1.*状态未知 2/); for (const [status, frozen, expected] of [['complete',true,'已冻结'],['loading',false,'加载中'],['unloaded',false,'尚未加载'],['unknown',false,'状态未知']]) { rows=[{...rows[0],discarded:false,frozen,status}]; await elements.refresh.listeners.click(); - assert.ok(elements.tabs.children[0].children[3].textContent.includes(expected)); + assert.ok(textOf(elements.tabs.children[0].children[3]).includes(expected)); assert.equal(elements.tabs.children[0].children[1].children[0].textContent,'New DOM'); } fail=true;await elements.sync.listeners.click();assert.equal(elements.sync.disabled,false);assert.match(elements.notice.textContent,/同步失败/); }); + +test('bridge failure keeps list actions available and clears stale detail after recovery',async()=>{ + const {runInNewContext}=await import('node:vm'); + class Element { + constructor(){this.children=[];this.listeners={};this.value='';this.classList={toggle(){}};} + addEventListener(type,fn){this.listeners[type]=fn;}setAttribute(){}append(...items){this.children.push(...items);}replaceChildren(){this.children=[];} + } + const elements={},event={addListener(){}},copied=[]; + const rows=[{tabId:1,windowId:1,index:0,url:'https://example.com/',title:'Page',titleState:'captured',active:false}]; + let bridgeResult={state:'disconnected',message:'Specified native messaging host not found'}; + const context={...core,crypto:{randomUUID:()=> 'request'},setTimeout,window:{addEventListener(){}}, + document:{getElementById:id=>elements[id] ||= new Element(),createElement:()=>new Element()},navigator:{clipboard:{async writeText(text){copied.push(text);}}}, + chrome:{runtime:{async sendMessage(request){if(request.op==='list')return {ok:true,sessionId:'s',tabs:rows};return bridgeResult;}}, + tabs:{onCreated:event,onRemoved:event,onUpdated:event,onActivated:event},storage:{onChanged:event}}, + }; + const ui=(await readFile(new URL('../extension/control.js',import.meta.url),'utf8')).replace(/^import .*;\n/,''); + runInNewContext(ui,context);await new Promise(resolve=>setImmediate(resolve)); + assert.equal(elements['bridge-status'].textContent,'Alfred 连接未就绪'); + assert.equal(elements['bridge-detail'].hidden,false); + assert.equal(elements['bridge-detail'].textContent,bridgeResult.message); + assert.equal(elements.tabs.children.length,1); + elements.tabs.children[0].listeners.click({}); + await elements['copy-urls'].listeners.click(); + assert.deepEqual(copied,['https://example.com/']); + bridgeResult={state:'connected'}; + await elements.refresh.listeners.click(); + assert.equal(elements['bridge-status'].textContent,'Alfred 连接就绪'); + assert.equal(elements['bridge-detail'].hidden,true); + assert.equal(elements['bridge-detail'].textContent,''); +}); From 4e930f054a34d932f5d7e55ef37a3c3144d40c46 Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:32:34 +0800 Subject: [PATCH 6/9] fix: preserve Dia titles across lifecycle transitions --- docs/plugins/dia_tabs.md | 10 +- docs/specs/2026-11-01-dia-tabs-design.md | 14 +- plugins/dia_tabs/extension/state.js | 50 ++++-- plugins/dia_tabs/tests/background.test.mjs | 191 +++++++++++++++++++-- 4 files changed, 235 insertions(+), 30 deletions(-) diff --git a/docs/plugins/dia_tabs.md b/docs/plugins/dia_tabs.md index b8c4865..f983ffc 100644 --- a/docs/plugins/dia_tabs.md +++ b/docs/plugins/dia_tabs.md @@ -74,7 +74,15 @@ plugins/dia_tabs/install.sh \ | 网页原始标题为空 | 无标题 | URL | | 受限页面或读取失败 | 无法读取页面标题 | URL | -页面开始加载、导航或 URL 改变时作废旧标题。页面 active 且加载完成后重新读取;页面动态修改标题时继续更新。后台标签保留已采集且仍对应当前 URL 的标题,后台导航会清空旧标题。关闭标签后从列表移除;关闭后的延迟采集响应不会恢复旧记录。 +| 页面变化 | 标题处理 | +|---|---| +| URL 真正改变或正常重载(含同 URL 重载) | 立即作废旧标题;后台导航同样清空 | +| 重复上报相同 URL、浏览器自定义标签名变化 | 不直接清空或使用浏览器标签名;active 且加载完成时重新读取 DOM | +| 同 URL 页面冻结、释放或尚未加载,可能同时上报 loading | 保留已采集标题,拒绝跨越该状态的旧采样结果 | +| 解除冻结或恢复已释放页面 | active 且加载完成时补采;后台标签继续保留已采集标题 | +| active 页面修改 DOM title | 首次变化立即通知重新读取;150 毫秒内的后续变化合并为一次通知,不传递标题内容 | + +这里只根据 DOM 标题、active 和页面生命周期判断是否更新,不能严格判定每次标题变化是否由用户操作引起。极快切走时,尚未完成的自动采样会被拒绝,变化后的标题可能要等下次激活才采集。关闭标签后从列表移除;关闭后的延迟采集响应不会恢复旧记录。 仅列出 HTTP/HTTPS 网页。不支持隐私窗口,不读取 `file://`、`chrome://`、`dia://` 或扩展页面。Markdown 转义反斜线和方括号,标题换行归为空格;URL 中的空格、控制字符、括号、尖括号和反斜线做百分号编码,保留已有百分号编码。 diff --git a/docs/specs/2026-11-01-dia-tabs-design.md b/docs/specs/2026-11-01-dia-tabs-design.md index 5819f2c..87450dc 100644 --- a/docs/specs/2026-11-01-dia-tabs-design.md +++ b/docs/specs/2026-11-01-dia-tabs-design.md @@ -41,12 +41,14 @@ |---|---| | 打开的 HTTP/HTTPS 标签尚未被采集 | 显示“待访问”,保留 URL,可复制 URL、可激活 | | 自动采集:active 且 tab.status=complete,未 discarded/frozen | 在顶层 frame 读取 `document.title`、`location.href`、`document.readyState` | -| 初次启动、`onActivated`、`onUpdated` 的 complete/title 变化 | 满足上述条件时重新采集,不使用事件中的 title 值 | +| 初次启动、`onActivated`、`onUpdated` 的 complete/title 变化,或 frozen/discarded 变为 false | 满足上述条件时重新采集,不使用事件中的 title 值;后台恢复不自动采集 | | 非 active 标签 | 不自动采集;保留其已采集且仍对应当前 URL 的标题 | | 点击“同步已打开标签” | 手动读取当前 profile 全部 HTTP/HTTPS 已加载网页标题,包含后台标签;不切换、加载或重载页面 | | 手动同步遇到 loading/discarded/frozen | 跳过,保持页面状态;单次读取超过 5 秒也跳过 | -| 加载开始或 URL 变化 | 立即作废旧标题,增加该 tab 的采集版本;禁止旧标题与新 URL 配对 | -| SPA 改标题 | active 时由 `onUpdated` 的 title 变化触发重新读取 DOM | +| URL 真正改变或正常重载(含同 URL 重载) | 立即作废旧标题,增加该 tab 的采集版本;禁止旧标题与新 URL 配对 | +| 重复上报相同 URL | 只触发满足条件的重新采集,不作废已有标题 | +| 同 URL 标签 frozen/discarded/unloaded,可能同时上报 loading | 保留已采集标题,仍增加采集版本;恢复后也拒绝此前的在途结果 | +| DOM title 变化 | active 时由 `onUpdated` 的 title 事件或顶层 MutationObserver 通知触发重新读取 DOM | | SPA 改 URL | 作废旧标题,active 且 complete 时重新读取;后续 title 事件继续更新 | | 页面为空标题 | 显示“无标题”;Markdown 使用 URL 作为链接文本,不回退到 tabs.title | | 受限页面或执行脚本失败 | 保留 URL,显示“无法读取页面标题”;不得伪装采集成功 | @@ -54,7 +56,7 @@ 控制页和 Alfred 默认只列 HTTP/HTTPS 网页。`chrome://`、`dia://`、扩展自身页面、`file://` 不进入列表;不申请 file 权限。隐私窗口明确不支持:manifest 使用 `incognito: "not_allowed"`,并在查询结果再次过滤 incognito。 -标题采集不承诺存在一个永远稳定的标题。页面可以持续修改 title;记录的是最后一次满足条件时读到的 DOM title。可以用约 150 毫秒合并同一标签的密集更新,但不能做无期限“等待稳定”。Dia 重命名后是否仍投递 DOM title 的变化事件需要实测;若被 Dia 屏蔽,必须补顶层 DOM title 的 MutationObserver 通知后重新采样,不能悄悄丢失更新。 +标题采集不承诺存在一个永远稳定的标题。页面可以持续修改 title;记录的是最后一次满足条件时读到的 DOM title,不能严格归因到某次用户操作。顶层 MutationObserver 只关注 title 的增删和内容变化,首次变化立即发送无标题 payload 的通知,150 毫秒窗口内的后续变化在窗口结束时合并通知;持续变化不会无限等待稳定,style/meta 变化不触发通知。新版本采样会替换已有页面的 V1 observer,避免必须重载网页。Dia 手动重命名后动态标题的实机行为仍待验证。 ## 状态与并发边界 @@ -62,11 +64,11 @@ 记录至少包含 `tabId, url, title, titleState`。列表输出额外包含实时 `windowId, index, active`。导航版本计数用于拒绝延迟返回,不必对外暴露。 -采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。显式手动同步仅放宽 active 门槛,并在注入前再次读取实时标签状态;导航、关闭和更晚采样校验不变。导航事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 +采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。极快切走可能使自动采样来不及完成,下次激活再补采,不保证每次短暂访问都采到变化后的标题。显式手动同步仅放宽 active 门槛,并在注入前再次读取实时标签状态;导航、关闭和更晚采样校验不变。导航与生命周期暂停事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 手动同步最多并发 4 个实际未结束的脚本读取,重复请求复用同一批次。读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,额度不足时其余页面计为跳过,防止超时累积无限注入。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。同步响应另含 `skipReasons` 原因计数:`discarded`、`frozen`、`loading`、`unloaded`、`unknown_status`、`stale`、`closed`、`timeout`、`capacity`;已释放、冻结优先于 status 判定。仅返回本轮汇总,不持久化诊断。 -service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录。不能只依赖 `onStartup`:休眠后重启 worker 同样要初始化。 +service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录;初始化期间暂存的 URL、加载和生命周期事件随后按顺序应用,不包含 title。已关闭或被替换的标签不恢复;被早期事件作废的查询快照不用于初次采样,后续事件按实时状态补采。不能只依赖 `onStartup`:休眠后重启 worker 同样要初始化。 `connectNative` 保持连接以支持 Alfred 主动请求。断线后用有限重试与 `alarms` 恢复,重试间隔不低于数秒,长期失败最多每分钟一次;不得循环立即重连。控制页应显示本机桥接是否连接。即使 native host 不可用,扩展自身列表和复制仍正常工作。 diff --git a/plugins/dia_tabs/extension/state.js b/plugins/dia_tabs/extension/state.js index 5294f76..f300fbe 100644 --- a/plugins/dia_tabs/extension/state.js +++ b/plugins/dia_tabs/extension/state.js @@ -3,17 +3,24 @@ const isWeb = tab => !tab.incognito && /^https?:\/\//i.test(tab.url || ''); // Only this injected function reads page content. The observer sends a signal; // the worker checks active/complete again before each subsequent title read. export function readPageTitle() { - const key = '__lifeToolsDiaTitleObserverV1'; + const key = '__lifeToolsDiaTitleObserverV2'; if (!globalThis[key] && document.head) { - let timer; + globalThis.__lifeToolsDiaTitleObserverV1?.disconnect(); + delete globalThis.__lifeToolsDiaTitleObserverV1; + let timer, dirty = false; const observer = new MutationObserver(mutations => { const titleChanged = mutations.some(mutation => mutation.target.nodeName === 'TITLE' || mutation.target.parentNode?.nodeName === 'TITLE' || [...mutation.addedNodes, ...mutation.removedNodes].some(node => node.nodeName === 'TITLE')); - if (!titleChanged || timer) return; + if (!titleChanged) return; + if (timer) { dirty = true; return; } + chrome.runtime.sendMessage({type: 'dia-tabs-title-changed'}).catch(() => {}); timer = setTimeout(() => { timer = null; - chrome.runtime.sendMessage({type: 'dia-tabs-title-changed'}).catch(() => {}); + if (dirty) { + dirty = false; + chrome.runtime.sendMessage({type: 'dia-tabs-title-changed'}).catch(() => {}); + } }, 150); }); observer.observe(document.head, {subtree: true, childList: true, characterData: true}); @@ -26,7 +33,7 @@ export function readPageTitle() { // every asynchronous result is checked against both navigation and sample IDs. export function createTabService(api) { const records = new Map(), versions = new Map(), samples = new Map(), tasks = new Set(), initialRemovals = new Set(); - const pendingManual = new Set(); + const pendingManual = new Set(), initialUpdates = []; let sessionId, initialized = false, writeTail = Promise.resolve(), syncPromise = null; const persist = () => { writeTail = writeTail.catch(() => {}).then(() => api.storage.session.set({ @@ -39,13 +46,22 @@ export function createTabService(api) { promise.catch(() => {}).finally(() => tasks.delete(promise)); return promise; }; - const invalidate = (id, url) => { + const invalidate = (id, url, preserveTitle = false) => { versions.set(id, (versions.get(id) || 0) + 1); samples.set(id, (samples.get(id) || 0) + 1); - if (url && /^https?:\/\//i.test(url)) records.set(id, {url, title: '', titleState: 'pending'}); - else records.delete(id); + if (!preserveTitle) { + if (url && /^https?:\/\//i.test(url)) records.set(id, {url, title: '', titleState: 'pending'}); + else records.delete(id); + } if (sessionId) persist(); }; + const updateRecord = (id, url, loading, suspended) => { + const record = records.get(id), urlChanged = record?.url !== url; + // Suspension rejects in-flight reads even after recovery, while retaining + // the last DOM title for the same URL. A real reload still clears it. + if (urlChanged || loading || suspended) + invalidate(id, url, !urlChanged && suspended && record?.titleState === 'captured'); + }; const capture = async (tab, {allowBackground = false} = {}) => { if (!isWeb(tab)) return 'unsupported'; if (!allowBackground && !tab.active) return 'inactive'; @@ -105,17 +121,29 @@ export function createTabService(api) { for (const tab of tabs) { if (records.get(tab.id)?.url !== tab.url) records.set(tab.id, {url: tab.url, title: '', titleState: 'pending'}); } + // Compare early events with restored records, in order; the query snapshot + // may predate these events. Removed/replaced tabs must not be resurrected. + for (const update of initialUpdates) if (live.has(update[0])) updateRecord(...update); + initialUpdates.length = 0; initialized = true; initialRemovals.clear(); persist(); - await Promise.all(tabs.map(tab => capture(tab))); + await Promise.all(tabs.filter(tab => !versions.has(tab.id)).map(tab => capture(tab))); })(); const refreshTab = id => track(ready.then(async () => { try { const tab = await api.tabs.get(id); await capture(tab); } catch { /* Tab closed. */ } })); api.tabs.onUpdated.addListener((id, change, tab) => { - if (change.status === 'loading' || Object.hasOwn(change, 'url')) invalidate(id, tab.incognito ? undefined : change.url || tab.url); - if (change.status === 'complete' || Object.hasOwn(change, 'title') || Object.hasOwn(change, 'url')) refreshTab(id); + if (change.status === 'loading' || Object.hasOwn(change, 'url') + || change.frozen === true || change.discarded === true || change.status === 'unloaded') { + const update = [id, tab.incognito ? undefined : change.url || tab.url, change.status === 'loading', + !!(tab.frozen || tab.discarded || tab.status === 'unloaded' + || change.frozen === true || change.discarded === true || change.status === 'unloaded')]; + if (initialized) updateRecord(...update); + else initialUpdates.push(update); + } + if (change.status === 'complete' || Object.hasOwn(change, 'title') || Object.hasOwn(change, 'url') + || change.frozen === false || change.discarded === false) refreshTab(id); }); api.tabs.onActivated.addListener(({tabId}) => { refreshTab(tabId); }); api.tabs.onCreated.addListener(tab => { refreshTab(tab.id); }); diff --git a/plugins/dia_tabs/tests/background.test.mjs b/plugins/dia_tabs/tests/background.test.mjs index 48fd35f..4acd05e 100644 --- a/plugins/dia_tabs/tests/background.test.mjs +++ b/plugins/dia_tabs/tests/background.test.mjs @@ -1,8 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; +import {runInNewContext} from 'node:vm'; const source = await readFile(new URL('../extension/state.js', import.meta.url), 'utf8').catch(() => ''); -const {createTabService} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); +const {createTabService,readPageTitle} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); function event() { const listeners = []; @@ -89,22 +90,49 @@ test('script rejection is unavailable rather than tabs.title fallback', async () assert.equal(row.title,'');assert.equal(row.titleState,'unavailable'); }); -test('title observer ignores style/meta churn and coalesces title mutations without starvation', async () => { - const {runInNewContext} = await import('node:vm'); - const {readPageTitle} = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); - const callbacks=[],timers=[],messages=[]; +function titleObserver(extra={}) { + const callbacks=[],timers=[],delays=[],messages=[]; const context={document:{head:{},title:'DOM Title',readyState:'complete'},location:{href:'https://example.com/'}, MutationObserver:class {constructor(callback) {callbacks.push(callback);}observe() {}}, - setTimeout(fn) {timers.push(fn);return timers.length;},clearTimeout() {}, + setTimeout(fn,delay) {timers.push(fn);delays.push(delay);return timers.length;},clearTimeout() {}, chrome:{runtime:{sendMessage(message) {messages.push(message);return Promise.resolve();}}}, + ...extra, }; runInNewContext(`(${readPageTitle.toString()})()`,context); - callbacks[0]([{type:'childList',target:{nodeName:'STYLE'},addedNodes:[],removedNodes:[]}]); - assert.equal(timers.length,0); - const mutation={type:'characterData',target:{parentNode:{nodeName:'TITLE'}},addedNodes:[],removedNodes:[]}; - callbacks[0]([mutation]);callbacks[0]([mutation]); - assert.equal(timers.length,1); - timers[0]();assert.equal(messages.length,1); + const notify=()=>callbacks[0]([{type:'characterData',target:{parentNode:{nodeName:'TITLE'}},addedNodes:[],removedNodes:[]}]); + return {callbacks,timers,delays,messages,notify,context}; +} + +test('title observer immediately signals the first mutation without a title payload', () => { + const f=titleObserver();f.notify(); + assert.deepEqual(structuredClone(f.messages),[{type:'dia-tabs-title-changed'}]); + assert.equal(f.timers.length,1);assert.equal(f.delays[0],150); + f.timers[0]();assert.equal(f.messages.length,1); +}); + +test('title observer coalesces later mutations and does not starve continuous changes', () => { + const f=titleObserver();f.notify();f.notify();f.notify(); + assert.equal(f.messages.length,1);assert.equal(f.timers.length,1); + f.timers[0]();assert.equal(f.messages.length,2); + f.notify();f.notify();assert.equal(f.messages.length,3);assert.equal(f.timers.length,2); + f.timers[1]();assert.equal(f.messages.length,4); +}); + +test('title observer ignores style and meta changes without notifications or timers', () => { + const f=titleObserver(); + for(const nodeName of ['STYLE','META','LINK']) { + f.callbacks[0]([{type:'childList',target:{nodeName},addedNodes:[],removedNodes:[]}]); + } + assert.equal(f.timers.length,0);assert.deepEqual(f.messages,[]); +}); + +test('title observer replaces V1 on existing pages and installs the new observer only once', () => { + let disconnected=0; + const f=titleObserver({__lifeToolsDiaTitleObserverV1:{disconnect() {disconnected++;}}}); + assert.equal(disconnected,1);assert.equal(f.callbacks.length,1); + runInNewContext(`(${readPageTitle.toString()})()`,f.context); + assert.equal(disconnected,1);assert.equal(f.callbacks.length,1); + f.notify();assert.equal(f.messages.length,1); }); test('private-window events never persist URL or title even if API exposes them', async () => { @@ -233,3 +261,142 @@ test('list exposes live diagnostic fields and sync prioritizes discarded over un assert.deepEqual((await s.syncOpenTabs()).skipReasons,{discarded:1,unknown_status:1,loading:1,frozen:1,unloaded:1}); assert.equal(f.calls.length,0); }); + +test('active pages are sampled after unfreezing or restoration, but inactive pages are not', async t => { + for(const field of ['frozen','discarded']) for(const active of [true,false]) await t.test(`${field}, active=${active}`,async()=>{ + const f=fake([tab(1,{active:false,[field]:true})]);const s=service(f.api);await s.ready; + f.current.set(1,tab(1,{active,[field]:true}));f.api.tabs.onActivated.emit({tabId:1});await s.idle(); + assert.equal(f.calls.length,0); + f.current.set(1,tab(1,{active,[field]:false}));f.api.tabs.onUpdated.emit(1,{[field]:false},f.current.get(1));await s.idle(); + f.current.set(1,{...f.current.get(1),active:false}); + const row=(await s.list())[0]; + assert.equal(row.titleState,active?'captured':'pending'); + assert.equal(row.title,active?'DOM 1':''); + assert.equal(f.calls.filter(call=>call[0]==='script').length,active?1:0); + }); +}); + +test('repeated URLs preserve background titles while real URL changes clear them', async () => { + const f=fake([tab(1)]);const s=service(f.api);await s.ready; + f.current.set(1,tab(1,{active:false}));f.api.tabs.onUpdated.emit(1,{url:f.current.get(1).url,title:'Browser label'},f.current.get(1));await s.idle(); + assert.equal((await s.list())[0].title,'DOM 1'); + assert.equal(f.calls.filter(call=>call[0]==='script').length,1); + f.current.set(1,tab(1));f.api.tabs.onUpdated.emit(1,{url:f.current.get(1).url},f.current.get(1));await s.idle(); + assert.equal(f.calls.filter(call=>call[0]==='script').length,2); + f.current.set(1,tab(1,{active:false,url:'https://example.org/next'})); + f.api.tabs.onUpdated.emit(1,{url:f.current.get(1).url},f.current.get(1));await s.idle(); + const row=(await s.list())[0];assert.equal(row.title,'');assert.equal(row.titleState,'pending'); +}); + +test('repeated URLs keep an in-flight manual sample valid, while a real same-URL reload rejects it', async t => { + for(const reload of [false,true]) await t.test(`reload=${reload}`,async()=>{ + const f=fake([tab(1)]);const s=service(f.api);await s.ready; + f.current.set(1,tab(1,{active:false}));let release; + f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); + const pending=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve)); + f.current.set(1,tab(1,{active:false,status:reload?'loading':'complete'})); + f.api.tabs.onUpdated.emit(1,reload?{status:'loading'}:{url:f.current.get(1).url},f.current.get(1)); + f.current.set(1,tab(1,{active:false})); + if(reload) f.api.tabs.onUpdated.emit(1,{status:'complete'},f.current.get(1)); + release([{frameId:0,result:{title:'Current DOM',url:'https://example.com/',readyState:'complete'}}]); + assert.deepEqual(await pending,reload?{captured:0,unavailable:0,skipped:1,skipReasons:{stale:1}}:{captured:1,unavailable:0,skipped:0,skipReasons:{}}); + await s.idle();assert.equal((await s.list())[0].title,reload?'':'Current DOM'); + }); +}); + +test('lifecycle loading preserves captured DOM titles unless the URL changes', async t => { + for(const lifecycle of [{frozen:true},{discarded:true},{status:'unloaded'}]) await t.test(JSON.stringify(lifecycle),async()=>{ + const f=fake([tab(1)]);const s=service(f.api);await s.ready; + const state={active:false,status:'loading',title:'Browser placeholder',...lifecycle}; + f.current.set(1,tab(1,state)); + f.api.tabs.onUpdated.emit(1,{status:'loading',...lifecycle,title:'Browser placeholder'},f.current.get(1));await s.idle(); + const retained=(await s.list())[0];assert.equal(retained.title,'DOM 1');assert.equal(retained.titleState,'captured'); + f.current.set(1,tab(1,{...state,url:'https://example.org/next'})); + f.api.tabs.onUpdated.emit(1,{url:f.current.get(1).url,status:'loading'},f.current.get(1));await s.idle(); + const changed=(await s.list())[0];assert.equal(changed.title,'');assert.equal(changed.titleState,'pending'); + }); +}); + +test('a sample crossing a complete lifecycle suspension cannot replace the retained title', async t => { + for(const field of ['frozen','discarded']) for(const loading of [false,true]) await t.test(`${field}, loading=${loading}`,async()=>{ + const f=fake([tab(1)]);const s=service(f.api);await s.ready; + f.current.set(1,tab(1,{active:false}));let release; + f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); + const pending=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve)); + f.current.set(1,tab(1,{active:false,[field]:true,status:loading?'loading':'complete'})); + f.api.tabs.onUpdated.emit(1,{[field]:true,...loading?{status:'loading'}:{}},f.current.get(1)); + f.current.set(1,tab(1,{active:false,[field]:false})); + f.api.tabs.onUpdated.emit(1,{[field]:false},f.current.get(1)); + release([{frameId:0,result:{title:'Before suspension',url:'https://example.com/',readyState:'complete'}}]); + assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1,skipReasons:{stale:1}});await s.idle(); + assert.equal((await s.list())[0].title,'DOM 1'); + }); +}); + +test('an unloaded page rejects an earlier sample after returning to complete', async () => { + const f=fake([tab(1)]);const s=service(f.api);await s.ready; + f.current.set(1,tab(1,{active:false}));let release; + f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); + const pending=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve)); + f.current.set(1,tab(1,{active:false,status:'unloaded'}));f.api.tabs.onUpdated.emit(1,{status:'unloaded'},f.current.get(1)); + f.current.set(1,tab(1,{active:false}));f.api.tabs.onUpdated.emit(1,{status:'complete'},f.current.get(1)); + release([{frameId:0,result:{title:'Before unload',url:'https://example.com/',readyState:'complete'}}]); + assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1,skipReasons:{stale:1}}); + await s.idle();assert.equal((await s.list())[0].title,'DOM 1'); +}); + +test('an automatic sample finishing after the tab becomes inactive retains the previous title', async () => { + const f=fake([tab(1)]);const s=service(f.api);await s.ready;let release; + f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); + const pending=s.refreshTab(1);await new Promise(resolve=>setImmediate(resolve)); + f.current.set(1,tab(1,{active:false})); + release([{frameId:0,result:{title:'After leaving',url:'https://example.com/',readyState:'complete'}}]);await pending; + assert.equal((await s.list())[0].title,'DOM 1'); +}); + +test('initial updates distinguish repeated URLs, navigation and real same-URL reloads', async t => { + for(const change of ['repeat','navigation','roundtrip','reload','lifecycle']) await t.test(change,async()=>{ + const saved={diaTabsSession:'saved-session',diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{active:false})]);let release; + f.api.storage.session.get=()=>new Promise(resolve=>{release=resolve;}); + const s=service(f.api); + if(change==='navigation' || change==='roundtrip') { + f.current.set(1,tab(1,{active:false,url:'https://example.org/next'})); + f.api.tabs.onUpdated.emit(1,{url:f.current.get(1).url},f.current.get(1)); + } + if(change==='roundtrip') f.current.set(1,tab(1,{active:false})); + if(change==='reload') f.current.set(1,tab(1,{active:false,status:'loading'})); + if(change==='lifecycle') f.current.set(1,tab(1,{active:false,status:'loading',discarded:true})); + f.api.tabs.onUpdated.emit(1,change==='reload'?{status:'loading'}:change==='lifecycle'?{discarded:true,status:'loading'}:{url:f.current.get(1).url},f.current.get(1)); + release(saved);await s.ready;await s.idle(); + const row=(await s.list())[0],preserve=change==='repeat'||change==='lifecycle'; + assert.equal(row.title,preserve?'Saved DOM':'');assert.equal(row.titleState,preserve?'captured':'pending'); + assert.equal(f.calls.length,0); + }); +}); + +test('initial query updates cannot restore closed or replaced tab records', async t => { + for(const change of ['navigation','remove','replace']) await t.test(change,async()=>{ + const saved={diaTabsSession:'saved-session',diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{active:false})],saved);let release; + f.api.tabs.query=()=>new Promise(resolve=>{release=resolve;}); + const s=service(f.api);await new Promise(resolve=>setImmediate(resolve)); + f.current.set(1,tab(1,{active:false,url:'https://example.org/next'})); + f.api.tabs.onUpdated.emit(1,{url:f.current.get(1).url},f.current.get(1)); + if(change==='remove') {f.current.delete(1);f.api.tabs.onRemoved.emit(1);} + if(change==='replace') {f.current.delete(1);f.current.set(2,tab(2,{active:false}));f.api.tabs.onReplaced.emit(2,1);} + release([tab(1,{active:false})]);await s.ready;await s.idle(); + if(change==='navigation') assert.deepEqual(f.storage.diaTabsRecords[1],{url:'https://example.org/next',title:'',titleState:'pending'}); + else assert.equal(f.storage.diaTabsRecords[1],undefined); + }); +}); + +test('a stale initial query cannot inject into a page suspended while the query was pending', async () => { + const saved={diaTabsSession:'saved-session',diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1)],saved);let release; + f.api.tabs.query=()=>new Promise(resolve=>{release=resolve;}); + const s=service(f.api);await new Promise(resolve=>setImmediate(resolve)); + f.current.set(1,tab(1,{frozen:true}));f.api.tabs.onUpdated.emit(1,{frozen:true},f.current.get(1)); + release([tab(1)]);await s.ready;await s.idle(); + assert.equal(f.calls.length,0);assert.equal(f.storage.diaTabsRecords[1].title,'Saved DOM'); +}); From 4fb3e76fbb070554c3241c02d0771fafd3646308 Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:30:31 +0800 Subject: [PATCH 7/9] fix: capture visible Dia titles despite stale frozen flag --- docs/plugins/dia_tabs.md | 9 +- docs/specs/2026-11-01-dia-tabs-design.md | 11 +- plugins/dia_tabs/extension/control.js | 4 +- plugins/dia_tabs/extension/state.js | 87 +++++++---- plugins/dia_tabs/tests/background.test.mjs | 159 ++++++++++++++++++++- plugins/dia_tabs/tests/control.test.mjs | 7 +- 6 files changed, 235 insertions(+), 42 deletions(-) diff --git a/docs/plugins/dia_tabs.md b/docs/plugins/dia_tabs.md index f983ffc..19d12c0 100644 --- a/docs/plugins/dia_tabs.md +++ b/docs/plugins/dia_tabs.md @@ -65,7 +65,9 @@ plugins/dia_tabs/install.sh \ 自动采集只在标签是所在窗口的 active 标签、且加载完成时读取顶层网页 DOM;首次安装不会遍历访问所有后台网页。多个窗口各自选中的标签都可以采集。缓存存于 `chrome.storage.session`,浏览器重启或扩展重载后重新采集,不建立网页历史数据库。 -点击“同步已打开标签”可一次读取本 profile 已打开 HTTP/HTTPS 网页的 `document.title`,包括后台标签;不切换、加载或重载页面。加载中、已释放(discarded)、冻结(frozen)或读取超过 5 秒的页面会跳过,受限页面计为“无法读取”;采集期间导航、关闭或被更新采样替代的结果也计为跳过。最多同时保留 4 个未结束的手动读取;超时读取仍占用额度,额度不足时跳过其余页面。完成后刷新列表并保留仍存在的选择,Alfred 后续查询共用更新后的标题;“刷新列表”保持原有自动采集规则。 +点击“同步已打开标签”可一次读取本 profile 已打开 HTTP/HTTPS 网页的 `document.title`,包括后台标签;不切换、加载或重载页面。加载中、已释放(discarded)、后台被浏览器标记冻结(frozen)或读取超过 5 秒的页面会跳过,受限页面计为“无法读取”;采集期间导航、关闭或被更新采样替代的结果也计为跳过。手动读取与前台探测最多共享 4 个未结束的执行,同一标签不重复发起受限读取;超时执行仍占用额度,直到浏览器实际结束,额度不足时跳过其余页面。完成后刷新列表并保留仍存在的选择,Alfred 后续查询共用更新后的标题;“刷新列表”保持原有自动采集规则。 + +`frozen` 来自浏览器 Tabs API,扩展不会主动冻结页面。2026-11-01 在 Dia 1.55.1 实测存在前台页面可正常交互,但 API 仍报告 `frozen:true` 的情况。此时仅对当前聚焦窗口内的 active、complete、未释放页面进行最多 5 秒的前台探测;DOM 必须返回同 URL、complete、visible,返回后仍满足前台条件,才接受原始标题。失焦、切走或探测失败保留已有标题;真正导航仍清空。窗口重新获得焦点时补采当前标签,不探测后台冻结页面。 | 状态 | 列表显示 | 复制 Markdown 的链接文字 | |---|---|---| @@ -79,14 +81,14 @@ plugins/dia_tabs/install.sh \ | URL 真正改变或正常重载(含同 URL 重载) | 立即作废旧标题;后台导航同样清空 | | 重复上报相同 URL、浏览器自定义标签名变化 | 不直接清空或使用浏览器标签名;active 且加载完成时重新读取 DOM | | 同 URL 页面冻结、释放或尚未加载,可能同时上报 loading | 保留已采集标题,拒绝跨越该状态的旧采样结果 | -| 解除冻结或恢复已释放页面 | active 且加载完成时补采;后台标签继续保留已采集标题 | +| 解除冻结、恢复已释放页面或窗口恢复前台 | 满足采集条件时补采;后台标签继续保留已采集标题 | | active 页面修改 DOM title | 首次变化立即通知重新读取;150 毫秒内的后续变化合并为一次通知,不传递标题内容 | 这里只根据 DOM 标题、active 和页面生命周期判断是否更新,不能严格判定每次标题变化是否由用户操作引起。极快切走时,尚未完成的自动采样会被拒绝,变化后的标题可能要等下次激活才采集。关闭标签后从列表移除;关闭后的延迟采集响应不会恢复旧记录。 仅列出 HTTP/HTTPS 网页。不支持隐私窗口,不读取 `file://`、`chrome://`、`dia://` 或扩展页面。Markdown 转义反斜线和方括号,标题换行归为空格;URL 中的空格、控制字符、括号、尖括号和反斜线做百分号编码,保留已有百分号编码。 -同步完成后显示本轮跳过原因及数量:已释放、已冻结、加载中、尚未加载、状态未知、页面已变化、已关闭、读取超时或先前读取尚未结束。列表状态列显示浏览器当前状态;已记录标题可与“已释放”等当前状态同时显示。浏览器没有提供的状态显示为未知,不推断为加载完成。诊断只用于当前展示,不单独保存或记录网页 URL。 +同步完成后显示本轮跳过原因及数量:已释放、浏览器标记冻结、加载中、尚未加载、状态未知、页面已变化、已关闭、读取超时或先前读取尚未结束。列表状态列显示浏览器报告的状态;“浏览器标记冻结”可与已记录标题同时显示,不能据此判定页面无法交互。浏览器没有提供的状态显示为未知,不推断为加载完成。诊断只用于当前展示,不单独保存或记录网页 URL。 ## 本机桥接与排障 @@ -137,6 +139,7 @@ Python 集成测试需要在临时目录创建 Unix socket,不操作真实浏 |---|---| | Native 桥接与 Alfred 列表 | Chrome 用户目录注册成功;Alfred 搜索可以显示演示网页 | | DOM 标题与控制页 | 动态标题由 Alpha 更新为包含方括号和中文的标题;点击 URL 返回同一标签;所选单项 Markdown 在真实文本框粘贴准确;关闭演示标签后列表由 1 项变为 0 项 | +| 前台冻结标记兼容 | 已在真实网页复现 active、complete、可交互而 frozen=true;更新扩展后,激活原标签即自动采集 DOM 标题,控制页显示已记录,API 仍报告 frozen=true;未刷新网页或点击手动同步 | | 手动同步 | 测试页转到后台后修改 DOM title,控制页先保持旧标题;点击同步后更新为新标题,测试页未被激活、标签数量未增加。已释放、已冻结页面的跳过原因和数量已实测,Alfred 共用缓存数量一致 | | 待验证 | 手动重命名后动态标题、跨窗口/最小化、浏览器退出后的清理、Alfred 回车实际切换及 Cmd/Alt+回车实际复制 | diff --git a/docs/specs/2026-11-01-dia-tabs-design.md b/docs/specs/2026-11-01-dia-tabs-design.md index 87450dc..fc5a02d 100644 --- a/docs/specs/2026-11-01-dia-tabs-design.md +++ b/docs/specs/2026-11-01-dia-tabs-design.md @@ -41,10 +41,11 @@ |---|---| | 打开的 HTTP/HTTPS 标签尚未被采集 | 显示“待访问”,保留 URL,可复制 URL、可激活 | | 自动采集:active 且 tab.status=complete,未 discarded/frozen | 在顶层 frame 读取 `document.title`、`location.href`、`document.readyState` | -| 初次启动、`onActivated`、`onUpdated` 的 complete/title 变化,或 frozen/discarded 变为 false | 满足上述条件时重新采集,不使用事件中的 title 值;后台恢复不自动采集 | +| frozen=true,但 active、complete、未 discarded,且窗口明确 focused=true | 允许有界前台探测;仅接受同 URL、DOM complete/visible 且返回后仍在前台的标题 | +| 初次启动、`onActivated`、窗口获得焦点、`onUpdated` 的 complete/title 变化,或 frozen/discarded 变为 false | 满足上述条件时重新采集,不使用事件中的 title 值;后台恢复不自动采集 | | 非 active 标签 | 不自动采集;保留其已采集且仍对应当前 URL 的标题 | | 点击“同步已打开标签” | 手动读取当前 profile 全部 HTTP/HTTPS 已加载网页标题,包含后台标签;不切换、加载或重载页面 | -| 手动同步遇到 loading/discarded/frozen | 跳过,保持页面状态;单次读取超过 5 秒也跳过 | +| 手动同步遇到 loading/discarded 或后台 frozen | 跳过,保持页面状态;符合前台探测条件的 frozen 页面允许探测,单次读取超过 5 秒也跳过 | | URL 真正改变或正常重载(含同 URL 重载) | 立即作废旧标题,增加该 tab 的采集版本;禁止旧标题与新 URL 配对 | | 重复上报相同 URL | 只触发满足条件的重新采集,不作废已有标题 | | 同 URL 标签 frozen/discarded/unloaded,可能同时上报 loading | 保留已采集标题,仍增加采集版本;恢复后也拒绝此前的在途结果 | @@ -56,6 +57,8 @@ 控制页和 Alfred 默认只列 HTTP/HTTPS 网页。`chrome://`、`dia://`、扩展自身页面、`file://` 不进入列表;不申请 file 权限。隐私窗口明确不支持:manifest 使用 `incognito: "not_allowed"`,并在查询结果再次过滤 incognito。 +`frozen` 是 Tabs API 报告值,扩展不主动冻结页面。Dia 1.55.1 已实测前台可交互页面与 `frozen:true` 并存,因此不能仅凭该标记永久拒绝采集。前台探测在注入前重新核对 tab 和窗口焦点,缺少明确 focused=true 时仍跳过;探测失败、无 DOM 结果或 hidden 不覆盖既有标题。控制页使用“浏览器标记冻结”,保留报告值,不将其解释为页面一定停止运行。 + 标题采集不承诺存在一个永远稳定的标题。页面可以持续修改 title;记录的是最后一次满足条件时读到的 DOM title,不能严格归因到某次用户操作。顶层 MutationObserver 只关注 title 的增删和内容变化,首次变化立即发送无标题 payload 的通知,150 毫秒窗口内的后续变化在窗口结束时合并通知;持续变化不会无限等待稳定,style/meta 变化不触发通知。新版本采样会替换已有页面的 V1 observer,避免必须重载网页。Dia 手动重命名后动态标题的实机行为仍待验证。 ## 状态与并发边界 @@ -64,9 +67,9 @@ 记录至少包含 `tabId, url, title, titleState`。列表输出额外包含实时 `windowId, index, active`。导航版本计数用于拒绝延迟返回,不必对外暴露。 -采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。极快切走可能使自动采样来不及完成,下次激活再补采,不保证每次短暂访问都采到变化后的标题。显式手动同步仅放宽 active 门槛,并在注入前再次读取实时标签状态;导航、关闭和更晚采样校验不变。导航与生命周期暂停事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 +采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。前台探测可忽略仍为 true 的 frozen,但必须额外检查 DOM visible、窗口仍明确 focused=true;激活或窗口焦点变化同步推进探测版本,即使切走再返回也拒绝此前的探测结果。极快切走可能使自动采样来不及完成,下次激活再补采,不保证每次短暂访问都采到变化后的标题。显式手动同步仅放宽 active 门槛,并在注入前再次读取实时标签状态;导航、关闭和更晚采样校验不变。导航与生命周期暂停事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 -手动同步最多并发 4 个实际未结束的脚本读取,重复请求复用同一批次。读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,额度不足时其余页面计为跳过,防止超时累积无限注入。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。同步响应另含 `skipReasons` 原因计数:`discarded`、`frozen`、`loading`、`unloaded`、`unknown_status`、`stale`、`closed`、`timeout`、`capacity`;已释放、冻结优先于 status 判定。仅返回本轮汇总,不持久化诊断。 +手动同步与前台探测共享最多 4 个实际未结束的脚本读取,同一 tabId 已有受限执行时不再注入,去重和容量检查不推进采样版本;重复手动同步请求复用同一批次。普通非 frozen 自动采集保持原有语义。受限读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,晚到结果只释放额度,不更新标题。导航、关闭不提前释放执行额度,初始化中的探测也不无限阻塞。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。同步响应另含 `skipReasons` 原因计数:`discarded`、`frozen`、`loading`、`unloaded`、`unknown_status`、`stale`、`closed`、`timeout`、`capacity`;已释放、冻结优先于 status 判定。仅返回本轮汇总,不持久化诊断。 service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录;初始化期间暂存的 URL、加载和生命周期事件随后按顺序应用,不包含 title。已关闭或被替换的标签不恢复;被早期事件作废的查询快照不用于初次采样,后续事件按实时状态补采。不能只依赖 `onStartup`:休眠后重启 worker 同样要初始化。 diff --git a/plugins/dia_tabs/extension/control.js b/plugins/dia_tabs/extension/control.js index 0f1c6e8..0c79d25 100644 --- a/plugins/dia_tabs/extension/control.js +++ b/plugins/dia_tabs/extension/control.js @@ -81,7 +81,7 @@ function render() { primaryState.append(current); } const secondaryState = document.createElement('div'); secondaryState.className = 'state-line state-secondary'; - const pageState = tab.discarded ? {label:'已释放', kind:'discarded'} : tab.frozen ? {label:'已冻结', kind:'frozen'} : tab.status === 'loading' ? {label:'加载中', kind:'loading'} : tab.status === 'unloaded' ? {label:'尚未加载', kind:'unloaded'} : tab.status === 'complete' ? {label:'已加载', kind:'complete'} : {label:'状态未知', kind:'unknown'}; + const pageState = tab.discarded ? {label:'已释放', kind:'discarded'} : tab.frozen ? {label:'浏览器标记冻结', kind:'frozen'} : tab.status === 'loading' ? {label:'加载中', kind:'loading'} : tab.status === 'unloaded' ? {label:'尚未加载', kind:'unloaded'} : tab.status === 'complete' ? {label:'已加载', kind:'complete'} : {label:'状态未知', kind:'unknown'}; const page = document.createElement('span'); page.className = `state-badge ${pageState.kind}`; page.textContent = pageState.label; const windowLabel = document.createElement('span'); windowLabel.className = 'window-label'; windowLabel.textContent = `窗口 ${tab.windowId}`; secondaryState.append(page, windowLabel); stateCell.append(primaryState, secondaryState); @@ -142,7 +142,7 @@ async function syncOpenTabs() { const result = await chrome.runtime.sendMessage({type: 'dia-tabs-sync'}); if (!result?.ok) throw Error('sync failed'); await refresh(); - const labels = {discarded: '已释放', frozen: '已冻结', loading: '加载中', unloaded: '尚未加载', unknown_status: '状态未知', stale: '页面已变化', closed: '已关闭', timeout: '读取超时', capacity: '先前读取尚未结束'}; + const labels = {discarded: '已释放', frozen: '浏览器标记冻结', loading: '加载中', unloaded: '尚未加载', unknown_status: '状态未知', stale: '页面已变化', closed: '已关闭', timeout: '读取超时', capacity: '先前读取尚未结束'}; const reasons = Object.entries(result.skipReasons || {}).map(([reason, count]) => `${labels[reason] || '其他'} ${count} 个`).join(','); notify(`同步完成:成功 ${result.captured} 个,无法读取 ${result.unavailable} 个,跳过 ${result.skipped} 个${reasons ? `(${reasons})` : ''}。`, 'success'); } catch { notify('同步失败,请稍后重试。', 'error'); } diff --git a/plugins/dia_tabs/extension/state.js b/plugins/dia_tabs/extension/state.js index f300fbe..41a7b33 100644 --- a/plugins/dia_tabs/extension/state.js +++ b/plugins/dia_tabs/extension/state.js @@ -26,15 +26,15 @@ export function readPageTitle() { observer.observe(document.head, {subtree: true, childList: true, characterData: true}); globalThis[key] = observer; } - return {title: document.title, url: location.href, readyState: document.readyState}; + return {title: document.title, url: location.href, readyState: document.readyState, visibilityState: document.visibilityState}; } // Session state stays in one worker. Navigation invalidation is synchronous; // every asynchronous result is checked against both navigation and sample IDs. export function createTabService(api) { const records = new Map(), versions = new Map(), samples = new Map(), tasks = new Set(), initialRemovals = new Set(); - const pendingManual = new Set(), initialUpdates = []; - let sessionId, initialized = false, writeTail = Promise.resolve(), syncPromise = null; + const pendingReads = new Map(), initialUpdates = []; + let sessionId, initialized = false, writeTail = Promise.resolve(), syncPromise = null, probeEpoch = 0; const persist = () => { writeTail = writeTail.catch(() => {}).then(() => api.storage.session.set({ diaTabsSession: sessionId, diaTabsRecords: Object.fromEntries(records), @@ -62,47 +62,72 @@ export function createTabService(api) { if (urlChanged || loading || suspended) invalidate(id, url, !urlChanged && suspended && record?.titleState === 'captured'); }; + // Manual reads and foreground probes share actual execution slots. A timeout + // ends the wait, but only the browser settling the execution releases its slot. + const executeTitle = async (tab, bounded) => { + let timeout; + try { + const execution = api.scripting.executeScript({target: {tabId: tab.id, frameIds: [0]}, func: readPageTitle}); + if (bounded) { + pendingReads.set(tab.id, execution); + execution.then(() => pendingReads.delete(tab.id), () => pendingReads.delete(tab.id)); + } + const results = bounded ? await Promise.race([execution, new Promise(resolve => { + timeout = setTimeout(() => resolve(null), 5000); + })]) : await execution; + return results === null ? {error: 'timeout'} : {page: results.find(result => result.frameId === 0)?.result}; + } catch { return {error: 'unavailable'}; } + finally { clearTimeout(timeout); } + }; const capture = async (tab, {allowBackground = false} = {}) => { if (!isWeb(tab)) return 'unsupported'; if (!allowBackground && !tab.active) return 'inactive'; if (tab.discarded) return 'discarded'; - if (tab.frozen) return 'frozen'; + const foregroundProbe = tab.frozen === true && tab.active; + if (tab.frozen && !foregroundProbe) return 'frozen'; if (tab.status !== 'complete') return tab.status === 'loading' ? 'loading' : tab.status === 'unloaded' ? 'unloaded' : 'unknown_status'; - const version = versions.get(tab.id) || 0, sample = (samples.get(tab.id) || 0) + 1; - samples.set(tab.id, sample); + const bounded = allowBackground || foregroundProbe; + if (bounded && (pendingReads.has(tab.id) || pendingReads.size >= 4)) return 'capacity'; + const version = versions.get(tab.id) || 0, previousSample = samples.get(tab.id) || 0, epoch = probeEpoch; // A queued manual sample must not inject into a tab discarded/frozen since - // the batch snapshot. No activation, reload or other wake-up is attempted. - if (allowBackground) { + // the batch snapshot. Only an already active, focused page may probe a + // possibly stale frozen flag; no activation, reload or wake-up is requested. + if (bounded) { let current; try { current = await api.tabs.get(tab.id); } catch { return 'closed'; } if (current.discarded) return 'discarded'; - if (current.frozen) return 'frozen'; - if (!isWeb(current) || current.url !== tab.url || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return 'stale'; + if (current.frozen && !foregroundProbe) return 'frozen'; + if (foregroundProbe) { + if (!current.active) return 'inactive'; + try { if ((await api.windows.get(current.windowId)).focused !== true) return 'frozen'; } + catch { return 'frozen'; } + } + if (!isWeb(current) || current.url !== tab.url || (versions.get(tab.id) || 0) !== version + || (samples.get(tab.id) || 0) !== previousSample || (foregroundProbe && probeEpoch !== epoch)) return 'stale'; if (current.status !== 'complete') return current.status === 'loading' ? 'loading' : current.status === 'unloaded' ? 'unloaded' : 'unknown_status'; + tab = current; } - let page, timeout; - try { - if (allowBackground && pendingManual.size >= 4) return 'capacity'; - const execution = api.scripting.executeScript({target: {tabId: tab.id, frameIds: [0]}, func: readPageTitle}); - if (allowBackground) { - pendingManual.add(execution); - execution.then(() => pendingManual.delete(execution), () => pendingManual.delete(execution)); - } - const results = allowBackground ? await Promise.race([execution, new Promise(resolve => { - timeout = setTimeout(() => resolve(null), 5000); - })]) : await execution; - if (results === null) return 'timeout'; - page = results.find(result => result.frameId === 0)?.result; - } catch { /* Restricted pages remain visible with an explicit unavailable state. */ } - finally { clearTimeout(timeout); } + // Recheck after async gates and before changing sample IDs: duplicate list + // requests must neither overbook slots nor invalidate the original probe. + if (bounded && (pendingReads.has(tab.id) || pendingReads.size >= 4)) return 'capacity'; + const sample = (samples.get(tab.id) || 0) + 1; + samples.set(tab.id, sample); + const {page, error} = await executeTitle(tab, bounded); + if (error === 'timeout') return 'timeout'; let current; try { current = await api.tabs.get(tab.id); } catch { return 'closed'; } if (current.discarded) return 'discarded'; - if (current.frozen) return 'frozen'; + if (current.frozen && !foregroundProbe) return 'frozen'; + if (foregroundProbe) { + if (!current.active) return 'inactive'; + try { if ((await api.windows.get(current.windowId)).focused !== true) return 'frozen'; } + catch { return 'frozen'; } + } if (!isWeb(current) || current.url !== tab.url || (versions.get(tab.id) || 0) !== version || samples.get(tab.id) !== sample) return 'stale'; if (!allowBackground && !current.active) return 'inactive'; if (current.status !== 'complete') return current.status === 'loading' ? 'loading' : current.status === 'unloaded' ? 'unloaded' : 'unknown_status'; if (page && (page.url !== tab.url || page.readyState !== 'complete')) return 'stale'; + if (foregroundProbe && (probeEpoch !== epoch || page?.visibilityState !== 'visible' || typeof page?.title !== 'string')) return 'stale'; records.set(tab.id, {url: tab.url, title: typeof page?.title === 'string' ? page.title : '', titleState: typeof page?.title === 'string' ? 'captured' : 'unavailable'}); persist(); @@ -145,7 +170,15 @@ export function createTabService(api) { if (change.status === 'complete' || Object.hasOwn(change, 'title') || Object.hasOwn(change, 'url') || change.frozen === false || change.discarded === false) refreshTab(id); }); - api.tabs.onActivated.addListener(({tabId}) => { refreshTab(tabId); }); + api.tabs.onActivated.addListener(({tabId}) => { probeEpoch++; refreshTab(tabId); }); + api.windows.onFocusChanged.addListener(windowId => { + probeEpoch++; + if (windowId < 0) return; + track(ready.then(async () => { + const tabs = await api.tabs.query({windowId, active: true}); + await Promise.all(tabs.map(tab => refreshTab(tab.id))); + }).catch(() => {})); + }); api.tabs.onCreated.addListener(tab => { refreshTab(tab.id); }); api.tabs.onRemoved.addListener(id => { if (!initialized) initialRemovals.add(id); invalidate(id); }); api.tabs.onReplaced.addListener((added, removed) => { diff --git a/plugins/dia_tabs/tests/background.test.mjs b/plugins/dia_tabs/tests/background.test.mjs index 4acd05e..80ae63b 100644 --- a/plugins/dia_tabs/tests/background.test.mjs +++ b/plugins/dia_tabs/tests/background.test.mjs @@ -16,13 +16,14 @@ function fake(tabs, saved = {}) { const api = { tabs: { onUpdated:event(),onActivated:event(),onRemoved:event(),onReplaced:event(),onCreated:event(), - async query() {return [...current.values()].map(tab => ({...tab}));}, + async query(query={}) {return [...current.values()].filter(tab=>(query.windowId===undefined || tab.windowId===query.windowId) + && (query.active===undefined || tab.active===query.active)).map(tab => ({...tab}));}, async get(id) {if (!current.has(id)) throw Error('closed'); return {...current.get(id)};}, async update(id, changes) {const tab=await this.get(id); current.set(id,{...tab,...changes}); calls.push(['tab',id,changes]); return current.get(id);}, }, - scripting: {async executeScript({target}) {calls.push(['script',target.tabId]);return [{frameId:0,result:{title:`DOM ${target.tabId}`,url:current.get(target.tabId)?.url,readyState:'complete'}}];}}, + scripting: {async executeScript({target}) {calls.push(['script',target.tabId]);return [{frameId:0,result:{title:`DOM ${target.tabId}`,url:current.get(target.tabId)?.url,readyState:'complete',visibilityState:'visible'}}];}}, storage:{session:{async get() {return structuredClone(storage);},async set(value) {Object.assign(storage,structuredClone(value));}}}, - windows:{async get(id) {return {id,state:'minimized'};},async update(id,value) {calls.push(['window',id,value]);}}, + windows:{onFocusChanged:event(),async get(id) {return {id,state:'minimized'};},async update(id,value) {calls.push(['window',id,value]);}}, }; return {api,current,calls,storage}; } @@ -400,3 +401,155 @@ test('a stale initial query cannot inject into a page suspended while the query release([tab(1)]);await s.ready;await s.idle(); assert.equal(f.calls.length,0);assert.equal(f.storage.diaTabsRecords[1].title,'Saved DOM'); }); + +test('a frozen active page in the focused window can supply its visible DOM title', async () => { + const f=fake([tab(1,{frozen:true})]);f.api.windows.get=async id=>({id,focused:true}); + const s=service(f.api);await s.ready; + const row=(await s.list())[0]; + assert.equal(row.title,'DOM 1');assert.equal(row.titleState,'captured');assert.equal(row.frozen,true); + assert.deepEqual(f.calls,[['script',1]]); + const result=runInNewContext(`(${readPageTitle.toString()})()`,{ + document:{head:null,title:'Visible DOM',readyState:'complete',visibilityState:'visible'},location:{href:'https://example.com/'}, + }); + assert.equal(result.visibilityState,'visible'); +}); + +test('frozen probes require active complete non-discarded pages and an explicitly focused window', async t => { + for(const extra of [{active:false},{status:'loading'},{discarded:true},{incognito:true}]) await t.test(JSON.stringify(extra),async()=>{ + const f=fake([tab(1,{frozen:true,...extra})]);f.api.windows.get=async id=>({id,focused:true}); + const s=service(f.api);await s.ready;await s.list();await s.syncOpenTabs(); + assert.deepEqual(f.calls,[]); + }); + for(const focused of [false,undefined]) await t.test(`focused=${focused}`,async()=>{ + const f=fake([tab(1,{frozen:true})]);f.api.windows.get=async id=>({id,focused}); + const s=service(f.api);await s.ready;await s.list();await s.syncOpenTabs(); + assert.deepEqual(f.calls,[]); + }); +}); + +test('failed or hidden frozen probes retain the previously captured title', async t => { + for(const kind of ['reject','no-result','hidden','missing-visibility','loading','wrong-url','missing-title']) await t.test(kind,async()=>{ + const saved={diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{active:false,frozen:true})],saved);f.api.windows.get=async id=>({id,focused:true}); + const s=service(f.api);await s.ready;let executions=0; + f.api.scripting.executeScript=async()=>{ + executions++;if(kind==='reject')throw Error('restricted');if(kind==='no-result')return []; + return [{frameId:0,result:{title:kind==='missing-title'?undefined:'Probe DOM', + url:kind==='wrong-url'?'https://example.org/next':'https://example.com/', + readyState:kind==='loading'?'loading':'complete',visibilityState:kind==='hidden'?'hidden':kind==='missing-visibility'?undefined:'visible'}}]; + }; + f.current.set(1,tab(1,{frozen:true}));await s.refreshTab(1); + assert.equal(executions,1);assert.equal((await s.list())[0].title,'Saved DOM'); + assert.equal(f.storage.diaTabsRecords[1].titleState,'captured'); + }); +}); + +test('frozen probes reject switching away, focus loss, navigation, closure and newer samples', async t => { + for(const change of ['inactive','focus','navigation','close','newer','switch-roundtrip','focus-roundtrip']) await t.test(change,async()=>{ + const saved={diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{active:false,frozen:true})],saved);let focused=true; + f.api.windows.get=async id=>({id,focused});const s=service(f.api);await s.ready; + let release;f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); + f.current.set(1,tab(1,{frozen:true}));const pending=s.refreshTab(1);await new Promise(resolve=>setImmediate(resolve)); + assert.equal(typeof release,'function'); + if(change==='inactive'||change==='switch-roundtrip') { + f.current.set(1,tab(1,{active:false,frozen:true}));f.api.tabs.onActivated.emit({tabId:2,windowId:1}); + if(change==='switch-roundtrip') {f.current.set(1,tab(1,{frozen:true}));f.api.tabs.onActivated.emit({tabId:1,windowId:1});} + } else if(change==='focus'||change==='focus-roundtrip') { + focused=false;f.api.windows.onFocusChanged.emit(-1); + if(change==='focus-roundtrip') {focused=true;f.api.windows.onFocusChanged.emit(1);} + } else if(change==='navigation') { + f.current.set(1,tab(1,{active:false,frozen:true,url:'https://example.org/next'})); + f.api.tabs.onUpdated.emit(1,{url:f.current.get(1).url},f.current.get(1)); + } else if(change==='close') {f.current.delete(1);f.api.tabs.onRemoved.emit(1);} + else { + f.api.scripting.executeScript=async()=>[{frameId:0,result:{title:'Newer DOM',url:'https://example.com/',readyState:'complete'}}]; + f.current.set(1,tab(1,{frozen:false}));await s.refreshTab(1); + } + await new Promise(resolve=>setImmediate(resolve)); + release([{frameId:0,result:{title:'Old probe DOM',url:'https://example.com/',readyState:'complete',visibilityState:'visible'}}]); + await pending;await s.idle(); + assert.equal(f.storage.diaTabsRecords[1]?.title,change==='close'?undefined:change==='navigation'?'':change==='newer'?'Newer DOM':'Saved DOM'); + }); +}); + +test('a stalled frozen probe bounds ready to five seconds and late results only release its slot', async t => { + const saved={diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{frozen:true})],saved);f.api.windows.get=async id=>({id,focused:true}); + let release,executions=0;f.api.scripting.executeScript=()=>{executions++;return new Promise(resolve=>{release=resolve;});}; + t.mock.timers.enable({apis:['setTimeout']}); + const s=service(f.api);let ready=false;s.ready.then(()=>{ready=true;});await new Promise(resolve=>setImmediate(resolve)); + assert.equal(executions,1);assert.equal(ready,false); + t.mock.timers.tick(5000);await s.ready;await s.idle();assert.equal(ready,true); + assert.equal((await s.list())[0].title,'Saved DOM'); + await s.refreshTab(1);assert.equal(executions,1); + release([{frameId:0,result:{title:'Too late',url:'https://example.com/',readyState:'complete',visibilityState:'visible'}}]); + await new Promise(resolve=>setImmediate(resolve));assert.equal(f.storage.diaTabsRecords[1].title,'Saved DOM'); + f.api.scripting.executeScript=async()=>{executions++;return [{frameId:0,result:{title:'Fresh DOM',url:'https://example.com/',readyState:'complete',visibilityState:'visible'}}];}; + await s.refreshTab(1);assert.equal(executions,2);assert.equal((await s.list())[0].title,'Fresh DOM'); +}); + +test('repeated lists share a frozen tab execution without invalidating its sample', async () => { + const f=fake([tab(1,{active:false,frozen:true})]);f.api.windows.get=async id=>({id,focused:true}); + const s=service(f.api);await s.ready;let release,executions=0; + f.api.scripting.executeScript=()=>{executions++;return new Promise(resolve=>{release=resolve;});}; + f.current.set(1,tab(1,{frozen:true}));const first=s.list();await new Promise(resolve=>setImmediate(resolve)); + assert.equal(executions,1); + await Promise.all([s.list(),s.list(),s.refreshTab(1)]);assert.equal(executions,1); + release([{frameId:0,result:{title:'Shared DOM',url:'https://example.com/',readyState:'complete',visibilityState:'visible'}}]); + assert.equal((await first)[0].title,'Shared DOM');await s.idle(); +}); + +test('manual reads and parallel frozen probes share at most four actual executions', async () => { + const f=fake(Array.from({length:6},(_,i)=>tab(i+1,{active:false,windowId:i+1,frozen:i>=4}))); + f.api.windows.get=async id=>({id,focused:true});const s=service(f.api);await s.ready; + const releases=new Map();let executions=0,running=0,maximum=0; + f.api.scripting.executeScript=({target})=>{executions++;running++;maximum=Math.max(maximum,running);return new Promise(resolve=>{ + releases.set(target.tabId,()=>{running--;resolve([{frameId:0,result:{title:`Read ${target.tabId}`,url:'https://example.com/',readyState:'complete',visibilityState:'visible'}}]);}); + });}; + const manual=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve));assert.equal(executions,4); + f.current.set(5,tab(5,{windowId:5,frozen:true}));f.current.set(6,tab(6,{windowId:6,frozen:true})); + await Promise.all([s.refreshTab(5),s.refreshTab(6)]);assert.equal(executions,4); + releases.get(1)();await new Promise(resolve=>setImmediate(resolve)); + const probe=s.refreshTab(5);await new Promise(resolve=>setImmediate(resolve));assert.equal(executions,5); + await s.refreshTab(6);assert.equal(executions,5); + for(const [id,release]of releases)if(id!==1)release();await manual;await probe;await s.idle(); + assert.equal(maximum,4);assert.equal(f.storage.diaTabsRecords[5].title,'Read 5'); +}); + +test('concurrent frozen probes recheck shared capacity after asynchronous window checks', async () => { + const f=fake(Array.from({length:7},(_,i)=>tab(i+1,{windowId:i+1,active:false,frozen:true}))); + f.api.windows.get=async id=>({id,focused:true});const s=service(f.api);await s.ready; + const gates=[],releases=[];let executions=0; + f.api.windows.get=id=>new Promise(resolve=>gates.push(()=>resolve({id,focused:true}))); + f.api.scripting.executeScript=()=>{executions++;return new Promise(resolve=>releases.push(resolve));}; + for(const [id,current]of f.current)f.current.set(id,{...current,active:true}); + const pending=[...f.current.keys()].map(id=>s.refreshTab(id));await new Promise(resolve=>setImmediate(resolve)); + assert.equal(gates.length,7);for(const release of gates.splice(0))release();await new Promise(resolve=>setImmediate(resolve)); + assert.equal(executions,4); + f.api.windows.get=async id=>({id,focused:true}); + for(let i=0;i { + const f=fake([tab(1,{windowId:1,frozen:true}),tab(2,{windowId:2,frozen:true}),tab(3,{windowId:2,active:false,frozen:true})]);let focusedWindow=-1; + f.api.windows.get=async id=>({id,focused:id===focusedWindow});const s=service(f.api);await s.ready; + assert.deepEqual(f.calls,[]);focusedWindow=2;f.api.windows.onFocusChanged.emit(2);await s.idle(); + assert.deepEqual(f.calls,[['script',2]]);assert.equal(f.storage.diaTabsRecords[2].title,'DOM 2'); +}); + +test('a frozen probe rejects a focus roundtrip during its final window check after execution settled', async () => { + const saved={diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{active:false,frozen:true})],saved);const s=service(f.api);await s.ready; + let checks=0,release; + f.api.windows.get=id=>{ + checks++;if(checks===2)return new Promise(resolve=>{release=()=>resolve({id,focused:true});}); + return Promise.resolve({id,focused:checks===1}); + }; + f.current.set(1,tab(1,{frozen:true}));const pending=s.refreshTab(1);await new Promise(resolve=>setImmediate(resolve)); + assert.equal(checks,2); + f.api.windows.onFocusChanged.emit(-1);f.api.windows.onFocusChanged.emit(1); + await new Promise(resolve=>setImmediate(resolve));release();await pending;await s.idle(); + assert.equal(f.storage.diaTabsRecords[1].title,'Saved DOM'); +}); diff --git a/plugins/dia_tabs/tests/control.test.mjs b/plugins/dia_tabs/tests/control.test.mjs index a5dd352..5d9cb16 100644 --- a/plugins/dia_tabs/tests/control.test.mjs +++ b/plugins/dia_tabs/tests/control.test.mjs @@ -134,12 +134,13 @@ test('sync button shows counts, preserves selection, ignores repeat clicks and r assert.equal(typeof elements.sync?.listeners.click,'function'); const pending=elements.sync.listeners.click();assert.equal(elements.sync.disabled,true);assert.equal(elements['sync-label'].textContent,'正在同步…'); elements.sync.listeners.click();assert.equal(syncCalls,1); - rows=[{...rows[0],title:'New DOM'}];resolveSync({ok:true,captured:1,unavailable:2,skipped:3,skipReasons:{discarded:1,unknown_status:2}});await pending; + rows=[{...rows[0],title:'New DOM'}];resolveSync({ok:true,captured:1,unavailable:2,skipped:3,skipReasons:{discarded:1,frozen:1,unknown_status:1}});await pending; assert.equal(elements.sync.disabled,false);assert.equal(elements['sync-label'].textContent,'同步已打开标签'); assert.equal(elements.tabs.children[0].children[0].children[0].checked,true); assert.equal(elements.tabs.children[0].children[1].children[0].textContent,'New DOM'); - assert.match(elements.notice.textContent,/成功 1.*无法读取 2.*跳过 3.*已释放 1.*状态未知 2/); - for (const [status, frozen, expected] of [['complete',true,'已冻结'],['loading',false,'加载中'],['unloaded',false,'尚未加载'],['unknown',false,'状态未知']]) { + assert.match(elements.notice.textContent,/成功 1.*无法读取 2.*跳过 3.*已释放 1.*状态未知 1/); + assert.match(elements.notice.textContent,/浏览器标记冻结 1 个/); + for (const [status, frozen, expected] of [['complete',true,'浏览器标记冻结'],['loading',false,'加载中'],['unloaded',false,'尚未加载'],['unknown',false,'状态未知']]) { rows=[{...rows[0],discarded:false,frozen,status}]; await elements.refresh.listeners.click(); assert.ok(textOf(elements.tabs.children[0].children[3]).includes(expected)); From 72cfae5df269ed9cc545362ce50058d0bac08212 Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:42:05 +0800 Subject: [PATCH 8/9] feat: include frozen Dia tabs in manual title sync --- docs/plugins/dia_tabs.md | 6 +- docs/specs/2026-11-01-dia-tabs-design.md | 10 +- plugins/dia_tabs/extension/control.html | 2 +- plugins/dia_tabs/extension/state.js | 25 ++-- plugins/dia_tabs/tests/background.test.mjs | 127 +++++++++++++++------ 5 files changed, 114 insertions(+), 56 deletions(-) diff --git a/docs/plugins/dia_tabs.md b/docs/plugins/dia_tabs.md index 19d12c0..ec25307 100644 --- a/docs/plugins/dia_tabs.md +++ b/docs/plugins/dia_tabs.md @@ -65,9 +65,9 @@ plugins/dia_tabs/install.sh \ 自动采集只在标签是所在窗口的 active 标签、且加载完成时读取顶层网页 DOM;首次安装不会遍历访问所有后台网页。多个窗口各自选中的标签都可以采集。缓存存于 `chrome.storage.session`,浏览器重启或扩展重载后重新采集,不建立网页历史数据库。 -点击“同步已打开标签”可一次读取本 profile 已打开 HTTP/HTTPS 网页的 `document.title`,包括后台标签;不切换、加载或重载页面。加载中、已释放(discarded)、后台被浏览器标记冻结(frozen)或读取超过 5 秒的页面会跳过,受限页面计为“无法读取”;采集期间导航、关闭或被更新采样替代的结果也计为跳过。手动读取与前台探测最多共享 4 个未结束的执行,同一标签不重复发起受限读取;超时执行仍占用额度,直到浏览器实际结束,额度不足时跳过其余页面。完成后刷新列表并保留仍存在的选择,Alfred 后续查询共用更新后的标题;“刷新列表”保持原有自动采集规则。 +点击“同步已打开标签”会尝试读取本 profile 已打开 HTTP/HTTPS 网页的 `document.title`,包括后台及浏览器标记冻结(frozen)的标签,不要求页面可见或窗口聚焦;不主动切换、加载或重载页面。加载中、已释放(discarded)、尚未加载或读取超过 5 秒的页面会跳过,受限页面计为“无法读取”;采集期间导航、关闭或被更新采样替代的结果也计为跳过。手动读取最多占用 3 个未结束的执行,与自动前台探测合计最多 4 个,同一标签不重复发起受限读取。真正冻结的页面可能不执行脚本而超时;超时执行仍占用额度,直到浏览器实际结束。已有 3 个受限执行未结束时,其余手动读取计为额度不足并跳过,不保证所有标签都实际注入或同步成功。单页读取失败或超时保留同 URL 的已采集标题;真正导航仍清空。完成后刷新列表并保留仍存在的选择,Alfred 后续查询共用更新后的标题;“刷新列表”保持原有自动采集规则。 -`frozen` 来自浏览器 Tabs API,扩展不会主动冻结页面。2026-11-01 在 Dia 1.55.1 实测存在前台页面可正常交互,但 API 仍报告 `frozen:true` 的情况。此时仅对当前聚焦窗口内的 active、complete、未释放页面进行最多 5 秒的前台探测;DOM 必须返回同 URL、complete、visible,返回后仍满足前台条件,才接受原始标题。失焦、切走或探测失败保留已有标题;真正导航仍清空。窗口重新获得焦点时补采当前标签,不探测后台冻结页面。 +`frozen` 来自浏览器 Tabs API,扩展不会主动冻结页面。2026-11-01 在 Dia 1.55.1 实测存在前台页面可正常交互,但 API 仍报告 `frozen:true` 的情况。自动采集仅对当前聚焦窗口内的 active、complete、未释放页面进行最多 5 秒的前台探测;DOM 必须返回同 URL、complete、visible,返回后仍满足前台条件,才接受原始标题。失焦、切走或探测失败保留已有标题;真正导航仍清空。窗口重新获得焦点时补采当前标签,自动采集不探测后台冻结页面;显式手动同步可尝试读取。 | 状态 | 列表显示 | 复制 Markdown 的链接文字 | |---|---|---| @@ -140,7 +140,7 @@ Python 集成测试需要在临时目录创建 Unix socket,不操作真实浏 | Native 桥接与 Alfred 列表 | Chrome 用户目录注册成功;Alfred 搜索可以显示演示网页 | | DOM 标题与控制页 | 动态标题由 Alpha 更新为包含方括号和中文的标题;点击 URL 返回同一标签;所选单项 Markdown 在真实文本框粘贴准确;关闭演示标签后列表由 1 项变为 0 项 | | 前台冻结标记兼容 | 已在真实网页复现 active、complete、可交互而 frozen=true;更新扩展后,激活原标签即自动采集 DOM 标题,控制页显示已记录,API 仍报告 frozen=true;未刷新网页或点击手动同步 | -| 手动同步 | 测试页转到后台后修改 DOM title,控制页先保持旧标题;点击同步后更新为新标题,测试页未被激活、标签数量未增加。已释放、已冻结页面的跳过原因和数量已实测,Alfred 共用缓存数量一致 | +| 手动同步 | 测试页转到后台后修改 DOM title,控制页先保持旧标题;点击同步后更新为新标题,测试页未被激活、标签数量未增加。已释放页面的跳过原因和数量已实测,Alfred 共用缓存数量一致 | | 待验证 | 手动重命名后动态标题、跨窗口/最小化、浏览器退出后的清理、Alfred 回车实际切换及 Cmd/Alt+回车实际复制 | Alfred 修饰键复制已通过参数和 `pbcopy` 的离线测试;组合键的实机交互尚未确认,不能据此声称验收完成。 diff --git a/docs/specs/2026-11-01-dia-tabs-design.md b/docs/specs/2026-11-01-dia-tabs-design.md index fc5a02d..259c68f 100644 --- a/docs/specs/2026-11-01-dia-tabs-design.md +++ b/docs/specs/2026-11-01-dia-tabs-design.md @@ -44,8 +44,8 @@ | frozen=true,但 active、complete、未 discarded,且窗口明确 focused=true | 允许有界前台探测;仅接受同 URL、DOM complete/visible 且返回后仍在前台的标题 | | 初次启动、`onActivated`、窗口获得焦点、`onUpdated` 的 complete/title 变化,或 frozen/discarded 变为 false | 满足上述条件时重新采集,不使用事件中的 title 值;后台恢复不自动采集 | | 非 active 标签 | 不自动采集;保留其已采集且仍对应当前 URL 的标题 | -| 点击“同步已打开标签” | 手动读取当前 profile 全部 HTTP/HTTPS 已加载网页标题,包含后台标签;不切换、加载或重载页面 | -| 手动同步遇到 loading/discarded 或后台 frozen | 跳过,保持页面状态;符合前台探测条件的 frozen 页面允许探测,单次读取超过 5 秒也跳过 | +| 点击“同步已打开标签” | 尝试读取当前 profile HTTP/HTTPS 已加载网页标题,包含后台及标记 frozen 的标签,不要求 active、focused 或 DOM visible;不主动切换、加载或重载页面 | +| 手动同步遇到 loading/discarded/unloaded | 跳过,保持页面状态;标记 frozen 且 complete 的页面仍尝试读取,单次读取超过 5 秒也跳过 | | URL 真正改变或正常重载(含同 URL 重载) | 立即作废旧标题,增加该 tab 的采集版本;禁止旧标题与新 URL 配对 | | 重复上报相同 URL | 只触发满足条件的重新采集,不作废已有标题 | | 同 URL 标签 frozen/discarded/unloaded,可能同时上报 loading | 保留已采集标题,仍增加采集版本;恢复后也拒绝此前的在途结果 | @@ -57,7 +57,7 @@ 控制页和 Alfred 默认只列 HTTP/HTTPS 网页。`chrome://`、`dia://`、扩展自身页面、`file://` 不进入列表;不申请 file 权限。隐私窗口明确不支持:manifest 使用 `incognito: "not_allowed"`,并在查询结果再次过滤 incognito。 -`frozen` 是 Tabs API 报告值,扩展不主动冻结页面。Dia 1.55.1 已实测前台可交互页面与 `frozen:true` 并存,因此不能仅凭该标记永久拒绝采集。前台探测在注入前重新核对 tab 和窗口焦点,缺少明确 focused=true 时仍跳过;探测失败、无 DOM 结果或 hidden 不覆盖既有标题。控制页使用“浏览器标记冻结”,保留报告值,不将其解释为页面一定停止运行。 +`frozen` 是 Tabs API 报告值,扩展不主动冻结页面。Dia 1.55.1 已实测前台可交互页面与 `frozen:true` 并存,因此不能仅凭该标记永久拒绝采集。自动前台探测在注入前重新核对 tab 和窗口焦点,缺少明确 focused=true 时仍跳过;探测失败、无 DOM 结果或 hidden 不覆盖既有标题。显式手动同步允许尝试标记 frozen 的后台页面,接受同 URL、DOM complete 的标题;若页面真正冻结而不执行脚本,按超时和额度规则处理。控制页使用“浏览器标记冻结”,保留报告值,不将其解释为页面一定停止运行。 标题采集不承诺存在一个永远稳定的标题。页面可以持续修改 title;记录的是最后一次满足条件时读到的 DOM title,不能严格归因到某次用户操作。顶层 MutationObserver 只关注 title 的增删和内容变化,首次变化立即发送无标题 payload 的通知,150 毫秒窗口内的后续变化在窗口结束时合并通知;持续变化不会无限等待稳定,style/meta 变化不触发通知。新版本采样会替换已有页面的 V1 observer,避免必须重载网页。Dia 手动重命名后动态标题的实机行为仍待验证。 @@ -67,9 +67,9 @@ 记录至少包含 `tabId, url, title, titleState`。列表输出额外包含实时 `windowId, index, active`。导航版本计数用于拒绝延迟返回,不必对外暴露。 -采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。前台探测可忽略仍为 true 的 frozen,但必须额外检查 DOM visible、窗口仍明确 focused=true;激活或窗口焦点变化同步推进探测版本,即使切走再返回也拒绝此前的探测结果。极快切走可能使自动采样来不及完成,下次激活再补采,不保证每次短暂访问都采到变化后的标题。显式手动同步仅放宽 active 门槛,并在注入前再次读取实时标签状态;导航、关闭和更晚采样校验不变。导航与生命周期暂停事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 +采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。前台探测可忽略仍为 true 的 frozen,但必须额外检查 DOM visible、窗口仍明确 focused=true;激活或窗口焦点变化同步推进探测版本,即使切走再返回也拒绝此前的探测结果。极快切走可能使自动采样来不及完成,下次激活再补采,不保证每次短暂访问都采到变化后的标题。显式手动同步放宽 active/frozen 限制,不检查窗口焦点、DOM visible 或前台探测版本,并在注入前再次读取实时标签状态;导航、关闭、实际生命周期事件和更晚采样校验不变。手动读取缺少有效字符串 title 时计为“无法读取”,但保留同 URL 的既有 captured 记录。导航与生命周期暂停事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 -手动同步与前台探测共享最多 4 个实际未结束的脚本读取,同一 tabId 已有受限执行时不再注入,去重和容量检查不推进采样版本;重复手动同步请求复用同一批次。普通非 frozen 自动采集保持原有语义。受限读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,晚到结果只释放额度,不更新标题。导航、关闭不提前释放执行额度,初始化中的探测也不无限阻塞。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。同步响应另含 `skipReasons` 原因计数:`discarded`、`frozen`、`loading`、`unloaded`、`unknown_status`、`stale`、`closed`、`timeout`、`capacity`;已释放、冻结优先于 status 判定。仅返回本轮汇总,不持久化诊断。 +手动同步最多占用 3 个实际未结束的脚本读取,与前台探测合计最多 4 个,为自动前台探测保留一个槽。已有 3 个受限执行未结束时,其余手动读取计为 capacity,不再注入;不保证每个标签都会实际尝试或成功。同一 tabId 已有受限执行时不再注入,去重和容量检查不推进采样版本;重复手动同步请求复用同一批次。普通非 frozen 自动采集保持原有语义。受限读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,晚到结果只释放额度,不更新标题。导航、关闭不提前释放执行额度,初始化中的探测也不无限阻塞。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。同步响应另含 `skipReasons` 原因计数:`discarded`、`frozen`、`loading`、`unloaded`、`unknown_status`、`stale`、`closed`、`timeout`、`capacity`;已释放优先于 status 判定,frozen 原因保留兼容。仅返回本轮汇总,不持久化诊断。 service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录;初始化期间暂存的 URL、加载和生命周期事件随后按顺序应用,不包含 title。已关闭或被替换的标签不恢复;被早期事件作废的查询快照不用于初次采样,后续事件按实时状态补采。不能只依赖 `onStartup`:休眠后重启 worker 同样要初始化。 diff --git a/plugins/dia_tabs/extension/control.html b/plugins/dia_tabs/extension/control.html index 0c1fd44..204a93a 100644 --- a/plugins/dia_tabs/extension/control.html +++ b/plugins/dia_tabs/extension/control.html @@ -66,7 +66,7 @@

标签管理

-

同步只读取已加载页面的原始标题,不切换或重载页面;加载中、已释放或冻结的页面会跳过。隐私窗口不在列表内。

+

同步会尝试读取已加载页面,包括浏览器标记冻结的页面,不切换或重载;加载中、已释放或尚未加载的页面会跳过。读取失败或超时会保留已有标题。隐私窗口不在列表内。

diff --git a/plugins/dia_tabs/extension/state.js b/plugins/dia_tabs/extension/state.js index 41a7b33..41a76f5 100644 --- a/plugins/dia_tabs/extension/state.js +++ b/plugins/dia_tabs/extension/state.js @@ -83,20 +83,21 @@ export function createTabService(api) { if (!isWeb(tab)) return 'unsupported'; if (!allowBackground && !tab.active) return 'inactive'; if (tab.discarded) return 'discarded'; - const foregroundProbe = tab.frozen === true && tab.active; - if (tab.frozen && !foregroundProbe) return 'frozen'; + const foregroundProbe = !allowBackground && tab.frozen === true && tab.active; + if (tab.frozen && !allowBackground && !foregroundProbe) return 'frozen'; if (tab.status !== 'complete') return tab.status === 'loading' ? 'loading' : tab.status === 'unloaded' ? 'unloaded' : 'unknown_status'; - const bounded = allowBackground || foregroundProbe; - if (bounded && (pendingReads.has(tab.id) || pendingReads.size >= 4)) return 'capacity'; + // Reserve one of the four bounded execution slots for automatic foreground probes. + const bounded = allowBackground || foregroundProbe, readLimit = allowBackground ? 3 : 4; + if (bounded && (pendingReads.has(tab.id) || pendingReads.size >= readLimit)) return 'capacity'; const version = versions.get(tab.id) || 0, previousSample = samples.get(tab.id) || 0, epoch = probeEpoch; - // A queued manual sample must not inject into a tab discarded/frozen since - // the batch snapshot. Only an already active, focused page may probe a - // possibly stale frozen flag; no activation, reload or wake-up is requested. + // Manual sync also attempts pages reported frozen, but never discarded + // pages. Automatic frozen probes still require an active, focused page. + // No activation or reload is requested. if (bounded) { let current; try { current = await api.tabs.get(tab.id); } catch { return 'closed'; } if (current.discarded) return 'discarded'; - if (current.frozen && !foregroundProbe) return 'frozen'; + if (current.frozen && !allowBackground && !foregroundProbe) return 'frozen'; if (foregroundProbe) { if (!current.active) return 'inactive'; try { if ((await api.windows.get(current.windowId)).focused !== true) return 'frozen'; } @@ -109,7 +110,7 @@ export function createTabService(api) { } // Recheck after async gates and before changing sample IDs: duplicate list // requests must neither overbook slots nor invalidate the original probe. - if (bounded && (pendingReads.has(tab.id) || pendingReads.size >= 4)) return 'capacity'; + if (bounded && (pendingReads.has(tab.id) || pendingReads.size >= readLimit)) return 'capacity'; const sample = (samples.get(tab.id) || 0) + 1; samples.set(tab.id, sample); const {page, error} = await executeTitle(tab, bounded); @@ -117,7 +118,7 @@ export function createTabService(api) { let current; try { current = await api.tabs.get(tab.id); } catch { return 'closed'; } if (current.discarded) return 'discarded'; - if (current.frozen && !foregroundProbe) return 'frozen'; + if (current.frozen && !allowBackground && !foregroundProbe) return 'frozen'; if (foregroundProbe) { if (!current.active) return 'inactive'; try { if ((await api.windows.get(current.windowId)).focused !== true) return 'frozen'; } @@ -128,6 +129,8 @@ export function createTabService(api) { if (current.status !== 'complete') return current.status === 'loading' ? 'loading' : current.status === 'unloaded' ? 'unloaded' : 'unknown_status'; if (page && (page.url !== tab.url || page.readyState !== 'complete')) return 'stale'; if (foregroundProbe && (probeEpoch !== epoch || page?.visibilityState !== 'visible' || typeof page?.title !== 'string')) return 'stale'; + const record = records.get(tab.id); + if (allowBackground && typeof page?.title !== 'string' && record?.url === tab.url && record.titleState === 'captured') return 'unavailable'; records.set(tab.id, {url: tab.url, title: typeof page?.title === 'string' ? page.title : '', titleState: typeof page?.title === 'string' ? 'captured' : 'unavailable'}); persist(); @@ -210,7 +213,7 @@ export function createTabService(api) { const tabs = (await api.tabs.query({})).filter(isWeb); const counts = {captured: 0, unavailable: 0, skipped: 0, skipReasons: {}}; let next = 0; - await Promise.all(Array.from({length: Math.min(4, tabs.length)}, async () => { + await Promise.all(Array.from({length: Math.min(3, tabs.length)}, async () => { while (next < tabs.length) { const result = await capture(tabs[next++], {allowBackground: true}); if (result === 'captured' || result === 'unavailable') counts[result]++; diff --git a/plugins/dia_tabs/tests/background.test.mjs b/plugins/dia_tabs/tests/background.test.mjs index 80ae63b..4b82f2c 100644 --- a/plugins/dia_tabs/tests/background.test.mjs +++ b/plugins/dia_tabs/tests/background.test.mjs @@ -164,18 +164,18 @@ test('explicit sync reads complete background pages without activating or waking const execute=f.api.scripting.executeScript; f.api.scripting.executeScript=async options=>{if(options.target.tabId===8)throw Error('restricted');return execute(options);}; assert.equal(typeof s.syncOpenTabs,'function'); - assert.deepEqual(await s.syncOpenTabs(),{captured:2,unavailable:1,skipped:3,skipReasons:{loading:1,discarded:1,frozen:1}}); + assert.deepEqual(await s.syncOpenTabs(),{captured:3,unavailable:1,skipped:2,skipReasons:{loading:1,discarded:1}}); const rows=await s.list(); assert.equal(rows.find(row=>row.tabId===1).title,'DOM 1'); assert.equal(rows.find(row=>row.tabId===8).titleState,'unavailable'); - assert.deepEqual(f.calls.filter(call=>call[0]==='script').map(call=>call[1]).sort(),[1,2]); + assert.deepEqual(f.calls.filter(call=>call[0]==='script').map(call=>call[1]).sort(),[1,2,5]); assert.ok(f.calls.every(call=>call[0]==='script')); f.api.tabs.onUpdated.emit(2,{title:'Renamed browser label'},f.current.get(2));await s.idle(); - assert.equal(f.calls.filter(call=>call[0]==='script').length,2); + assert.equal(f.calls.filter(call=>call[0]==='script').length,3); assert.equal((await s.handleRequest({v:1,id:'native-sync',op:'sync'})).error,'BAD_REQUEST'); }); -test('sync shares one in-flight batch and never exceeds four simultaneous samples', async () => { +test('sync shares one in-flight batch and never exceeds three simultaneous samples', async () => { const f=fake(Array.from({length:9},(_,index)=>tab(index+1,{active:false}))); const s=service(f.api);await s.ready; const waiting=[];let running=0,maximum=0,total=0; @@ -185,13 +185,13 @@ test('sync shares one in-flight batch and never exceeds four simultaneous sample }); assert.equal(typeof s.syncOpenTabs,'function'); const first=s.syncOpenTabs(),second=s.syncOpenTabs();assert.equal(first,second); - await new Promise(resolve=>setImmediate(resolve));assert.equal(running,4); + await new Promise(resolve=>setImmediate(resolve));assert.equal(running,3); while(total<9 || waiting.length) { for(const release of waiting.splice(0)) release(); await new Promise(resolve=>setImmediate(resolve)); } assert.deepEqual(await first,{captured:9,unavailable:0,skipped:0,skipReasons:{}}); - assert.equal(maximum,4);assert.equal(total,9); + assert.equal(maximum,3);assert.equal(total,9); }); test('manual background results are rejected after navigation, close or a later automatic sample', async t => { @@ -214,7 +214,7 @@ test('manual background results are rejected after navigation, close or a later }); }); -test('sync rechecks discarded/frozen before injection and rejects a page frozen during sampling', async () => { +test('sync rechecks discarded before injection and accepts a frozen flag without a lifecycle event', async () => { const f=fake([tab(1,{active:false}),tab(2,{active:false})]);const s=service(f.api);await s.ready; const query=f.api.tabs.query; f.api.tabs.query=async()=>{const snapshot=await query();f.current.set(1,tab(1,{active:false,discarded:true}));return snapshot;}; @@ -224,11 +224,12 @@ test('sync rechecks discarded/frozen before injection and rejects a page frozen return [{frameId:0,result:{title:'Frozen result',url:'https://example.com/',readyState:'complete'}}]; }; assert.equal(typeof s.syncOpenTabs,'function'); - assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:2,skipReasons:{discarded:1,frozen:1}});assert.equal(scripts,1); + assert.deepEqual(await s.syncOpenTabs(),{captured:1,unavailable:0,skipped:1,skipReasons:{discarded:1}});assert.equal(scripts,1); }); -test('a stalled manual script times out as skipped and late execution cannot restore its result', async t => { - const f=fake([tab(1,{active:false})]);const s=service(f.api);await s.ready; +test('a stalled frozen manual script times out without replacing the saved title', async t => { + const saved={diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{active:false,frozen:true})],saved);const s=service(f.api);await s.ready; let release;f.api.scripting.executeScript=()=>new Promise(resolve=>{release=resolve;}); t.mock.timers.enable({apis:['setTimeout']}); assert.equal(typeof s.syncOpenTabs,'function');const pending=s.syncOpenTabs(); @@ -236,18 +237,18 @@ test('a stalled manual script times out as skipped and late execution cannot res assert.deepEqual(await pending,{captured:0,unavailable:0,skipped:1,skipReasons:{timeout:1}}); release([{frameId:0,result:{title:'Too late',url:'https://example.com/',readyState:'complete'}}]); await new Promise(resolve=>setImmediate(resolve));await s.idle(); - assert.equal(f.storage.diaTabsRecords[1]?.title,''); + assert.equal(f.storage.diaTabsRecords[1]?.title,'Saved DOM'); }); -test('timed-out scripts keep the four execution slots across repeated sync batches', async t => { - const f=fake(Array.from({length:9},(_,index)=>tab(index+1,{active:false}))); +test('timed-out frozen scripts keep the three manual execution slots across repeated sync batches', async t => { + const f=fake(Array.from({length:9},(_,index)=>tab(index+1,{active:false,frozen:true}))); const s=service(f.api);await s.ready;const release=[];let calls=0; f.api.scripting.executeScript=()=>{calls++;return new Promise(resolve=>release.push(resolve));}; t.mock.timers.enable({apis:['setTimeout']}); - const first=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,4); - t.mock.timers.tick(5000);await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,4); - assert.deepEqual(await first,{captured:0,unavailable:0,skipped:9,skipReasons:{timeout:4,capacity:5}}); - assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:9,skipReasons:{capacity:9}});assert.equal(calls,4); + const first=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,3); + t.mock.timers.tick(5000);await new Promise(resolve=>setImmediate(resolve));assert.equal(calls,3); + assert.deepEqual(await first,{captured:0,unavailable:0,skipped:9,skipReasons:{timeout:3,capacity:6}}); + assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:9,skipReasons:{capacity:9}});assert.equal(calls,3); for(const resolve of release)resolve([{frameId:0,result:{title:'Late',url:'https://example.com/',readyState:'complete'}}]); await new Promise(resolve=>setImmediate(resolve)); }); @@ -259,8 +260,8 @@ test('list exposes live diagnostic fields and sync prioritizes discarded over un const rows=await s.list(); assert.equal(rows[0].status,'unloaded');assert.equal(rows[0].discarded,true); assert.equal(rows[1].status,'unknown');assert.equal(rows[1].discarded,null);assert.equal(rows[1].frozen,null); - assert.deepEqual((await s.syncOpenTabs()).skipReasons,{discarded:1,unknown_status:1,loading:1,frozen:1,unloaded:1}); - assert.equal(f.calls.length,0); + assert.deepEqual((await s.syncOpenTabs()).skipReasons,{discarded:1,unknown_status:1,loading:1,unloaded:1}); + assert.equal(f.calls.length,1); }); test('active pages are sampled after unfreezing or restoration, but inactive pages are not', async t => { @@ -414,15 +415,15 @@ test('a frozen active page in the focused window can supply its visible DOM titl assert.equal(result.visibilityState,'visible'); }); -test('frozen probes require active complete non-discarded pages and an explicitly focused window', async t => { +test('automatic frozen probes require active complete non-discarded pages and an explicitly focused window', async t => { for(const extra of [{active:false},{status:'loading'},{discarded:true},{incognito:true}]) await t.test(JSON.stringify(extra),async()=>{ const f=fake([tab(1,{frozen:true,...extra})]);f.api.windows.get=async id=>({id,focused:true}); - const s=service(f.api);await s.ready;await s.list();await s.syncOpenTabs(); + const s=service(f.api);await s.ready;await s.list(); assert.deepEqual(f.calls,[]); }); for(const focused of [false,undefined]) await t.test(`focused=${focused}`,async()=>{ const f=fake([tab(1,{frozen:true})]);f.api.windows.get=async id=>({id,focused}); - const s=service(f.api);await s.ready;await s.list();await s.syncOpenTabs(); + const s=service(f.api);await s.ready;await s.list(); assert.deepEqual(f.calls,[]); }); }); @@ -500,21 +501,25 @@ test('repeated lists share a frozen tab execution without invalidating its sampl assert.equal((await first)[0].title,'Shared DOM');await s.idle(); }); -test('manual reads and parallel frozen probes share at most four actual executions', async () => { - const f=fake(Array.from({length:6},(_,i)=>tab(i+1,{active:false,windowId:i+1,frozen:i>=4}))); +test('three stalled manual reads leave one foreground probe slot and release capacity only when settled', async t => { + const f=fake(Array.from({length:9},(_,i)=>tab(i+1,{active:false,frozen:true}))); f.api.windows.get=async id=>({id,focused:true});const s=service(f.api);await s.ready; - const releases=new Map();let executions=0,running=0,maximum=0; - f.api.scripting.executeScript=({target})=>{executions++;running++;maximum=Math.max(maximum,running);return new Promise(resolve=>{ - releases.set(target.tabId,()=>{running--;resolve([{frameId:0,result:{title:`Read ${target.tabId}`,url:'https://example.com/',readyState:'complete',visibilityState:'visible'}}]);}); - });}; - const manual=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve));assert.equal(executions,4); - f.current.set(5,tab(5,{windowId:5,frozen:true}));f.current.set(6,tab(6,{windowId:6,frozen:true})); - await Promise.all([s.refreshTab(5),s.refreshTab(6)]);assert.equal(executions,4); - releases.get(1)();await new Promise(resolve=>setImmediate(resolve)); - const probe=s.refreshTab(5);await new Promise(resolve=>setImmediate(resolve));assert.equal(executions,5); - await s.refreshTab(6);assert.equal(executions,5); - for(const [id,release]of releases)if(id!==1)release();await manual;await probe;await s.idle(); - assert.equal(maximum,4);assert.equal(f.storage.diaTabsRecords[5].title,'Read 5'); + const releases=[];let executions=0,running=0,maximum=0,recovered=false; + f.api.scripting.executeScript=({target})=>{ + executions++;running++;maximum=Math.max(maximum,running); + const result=[{frameId:0,result:{title:`Read ${target.tabId}`,url:'https://example.com/',readyState:'complete',visibilityState:'visible'}}]; + if(recovered || target.tabId===10) {running--;return Promise.resolve(result);} + return new Promise(resolve=>releases.push(()=>{running--;resolve(result);})); + }; + t.mock.timers.enable({apis:['setTimeout']}); + const manual=s.syncOpenTabs();await new Promise(resolve=>setImmediate(resolve));assert.equal(executions,3); + t.mock.timers.tick(5000);await new Promise(resolve=>setImmediate(resolve)); + assert.deepEqual(await manual,{captured:0,unavailable:0,skipped:9,skipReasons:{timeout:3,capacity:6}}); + assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:9,skipReasons:{capacity:9}});assert.equal(executions,3); + f.current.set(10,tab(10,{frozen:true}));await s.refreshTab(10); + assert.equal(f.storage.diaTabsRecords[10].title,'Read 10');assert.equal(executions,4);assert.equal(maximum,4); + for(const release of releases)release();await new Promise(resolve=>setImmediate(resolve));recovered=true; + assert.deepEqual(await s.syncOpenTabs(),{captured:10,unavailable:0,skipped:0,skipReasons:{}});assert.equal(maximum,4); }); test('concurrent frozen probes recheck shared capacity after asynchronous window checks', async () => { @@ -553,3 +558,53 @@ test('a frozen probe rejects a focus roundtrip during its final window check aft await new Promise(resolve=>setImmediate(resolve));release();await pending;await s.idle(); assert.equal(f.storage.diaTabsRecords[1].title,'Saved DOM'); }); + +test('manual sync reads hidden frozen background pages and unfocused active pages without automatic capture', async () => { + const f=fake([tab(1,{active:false,frozen:true}),tab(2,{windowId:2,frozen:true})]); + f.api.windows.get=async id=>({id,focused:false});const s=service(f.api);await s.ready; + await s.refreshTab(1);await s.refreshTab(2);assert.deepEqual(f.calls,[]); + f.api.windows.get=async()=>{throw Error('manual sync must not require a focused window');}; + f.api.scripting.executeScript=async({target})=>{f.calls.push(['script',target.tabId]);return [{frameId:0,result:{ + title:`Hidden DOM ${target.tabId}`,url:'https://example.com/',readyState:'complete',visibilityState:'hidden'}}];}; + assert.deepEqual(await s.syncOpenTabs(),{captured:2,unavailable:0,skipped:0,skipReasons:{}}); + assert.deepEqual(f.calls,[['script',1],['script',2]]); + assert.deepEqual((await s.list()).map(row=>row.title),['Hidden DOM 1','Hidden DOM 2']); +}); + +test('manual frozen reads allow reported flags and focus changes but reject actual lifecycle events', async t => { + for(const change of ['pre-flag','post-flag','focus-event','freeze-event']) await t.test(change,async()=>{ + const saved={diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{active:false})],saved);const s=service(f.api);await s.ready;let executions=0; + if(change==='pre-flag') { + const query=f.api.tabs.query;f.api.tabs.query=async()=>{const snapshot=await query();f.current.set(1,tab(1,{active:false,frozen:true}));return snapshot;}; + } + f.api.scripting.executeScript=async()=>{ + executions++;f.current.set(1,tab(1,{active:false,frozen:true})); + if(change==='focus-event')f.api.windows.onFocusChanged.emit(-1); + if(change==='freeze-event')f.api.tabs.onUpdated.emit(1,{frozen:true},f.current.get(1)); + return [{frameId:0,result:{title:'Hidden DOM',url:'https://example.com/',readyState:'complete',visibilityState:'hidden'}}]; + }; + assert.deepEqual(await s.syncOpenTabs(),change==='freeze-event'?{captured:0,unavailable:0,skipped:1,skipReasons:{stale:1}}:{captured:1,unavailable:0,skipped:0,skipReasons:{}}); + await s.idle();assert.equal(executions,1);assert.equal(f.storage.diaTabsRecords[1].title,change==='freeze-event'?'Saved DOM':'Hidden DOM'); + }); +}); + +test('manual frozen sync still skips discarded, loading and unloaded pages without injection', async () => { + const f=fake([tab(1,{active:false,frozen:true,discarded:true}),tab(2,{active:false,frozen:true,status:'loading'}),tab(3,{active:false,frozen:true,status:'unloaded'})]); + const s=service(f.api);await s.ready; + assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:3,skipReasons:{discarded:1,loading:1,unloaded:1}}); + assert.deepEqual(f.calls,[]); +}); + +test('failed manual reads count unavailable without replacing a captured title for the same URL', async t => { + for(const [kind,frozen]of [['reject',true],['no-frame',true],['missing-title',true],['reject',false]]) await t.test(`${kind}, frozen=${frozen}`,async()=>{ + const saved={diaTabsRecords:{1:{url:'https://example.com/',title:'Saved DOM',titleState:'captured'}}}; + const f=fake([tab(1,{active:false,frozen})],saved);const s=service(f.api);await s.ready;let executions=0; + f.api.scripting.executeScript=async()=>{ + executions++;if(kind==='reject')throw Error('restricted');if(kind==='no-frame')return []; + return [{frameId:0,result:{url:'https://example.com/',readyState:'complete',visibilityState:'hidden'}}]; + }; + assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:1,skipped:0,skipReasons:{}}); + await s.idle();assert.equal(executions,1);assert.deepEqual(f.storage.diaTabsRecords[1],saved.diaTabsRecords[1]); + }); +}); From 3723278e663d9b6e7eda2d72ccffae0fa2f8147b Mon Sep 17 00:00:00 2001 From: mcoder2014 <17683497+mcoder2014@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:07:20 +0800 Subject: [PATCH 9/9] fix: retain descriptive Dia titles on domain fallback --- docs/plugins/dia_tabs.md | 8 ++- docs/specs/2026-11-01-dia-tabs-design.md | 5 +- plugins/dia_tabs/extension/control.js | 2 +- plugins/dia_tabs/extension/state.js | 19 +++++++ plugins/dia_tabs/tests/background.test.mjs | 65 ++++++++++++++++++++++ plugins/dia_tabs/tests/control.test.mjs | 5 +- plugins/dia_tabs/tests/fixtures/alpha.html | 2 + 7 files changed, 100 insertions(+), 6 deletions(-) diff --git a/docs/plugins/dia_tabs.md b/docs/plugins/dia_tabs.md index ec25307..3b34d96 100644 --- a/docs/plugins/dia_tabs.md +++ b/docs/plugins/dia_tabs.md @@ -83,12 +83,15 @@ plugins/dia_tabs/install.sh \ | 同 URL 页面冻结、释放或尚未加载,可能同时上报 loading | 保留已采集标题,拒绝跨越该状态的旧采样结果 | | 解除冻结、恢复已释放页面或窗口恢复前台 | 满足采集条件时补采;后台标签继续保留已采集标题 | | active 页面修改 DOM title | 首次变化立即通知重新读取;150 毫秒内的后续变化合并为一次通知,不传递标题内容 | +| 同 URL 已记录的具体标题变成域名或完整域名段 | 保留旧标题;手动同步计入跳过,显示“标题退化,已保留” | + +标题退化保护适用于自动与手动采集:旧标题必须是同 URL 下已采集的非空、非域名标题。新标题按去除首尾空白、忽略大小写、去掉单个 `www.` 前缀和末尾点后,与完整 hostname 或连续完整域名段比较。例如 `tenant.example.com`、`tenant`、`tenant.example`、`example.com` 可以触发保护,任意字符片段不会。单段至少 3 个字符、包含 ASCII 字母,且不能只是最后的顶级域或 `www`;IP 和国际化域名只比较完整 hostname,不拆片段。首次域名标题仍可保存,域名标题可升级为具体标题;真实 URL 变化或重载后重新采集。此规则是启发式保护,同 URL 的 SPA 合法切回站点名时也可能保留旧标题;包含协议、端口或路径的标题不在本规则范围内。 这里只根据 DOM 标题、active 和页面生命周期判断是否更新,不能严格判定每次标题变化是否由用户操作引起。极快切走时,尚未完成的自动采样会被拒绝,变化后的标题可能要等下次激活才采集。关闭标签后从列表移除;关闭后的延迟采集响应不会恢复旧记录。 仅列出 HTTP/HTTPS 网页。不支持隐私窗口,不读取 `file://`、`chrome://`、`dia://` 或扩展页面。Markdown 转义反斜线和方括号,标题换行归为空格;URL 中的空格、控制字符、括号、尖括号和反斜线做百分号编码,保留已有百分号编码。 -同步完成后显示本轮跳过原因及数量:已释放、浏览器标记冻结、加载中、尚未加载、状态未知、页面已变化、已关闭、读取超时或先前读取尚未结束。列表状态列显示浏览器报告的状态;“浏览器标记冻结”可与已记录标题同时显示,不能据此判定页面无法交互。浏览器没有提供的状态显示为未知,不推断为加载完成。诊断只用于当前展示,不单独保存或记录网页 URL。 +同步完成后显示本轮跳过原因及数量:已释放、浏览器标记冻结、加载中、尚未加载、状态未知、页面已变化、已关闭、读取超时、先前读取尚未结束或“标题退化,已保留”。列表状态列显示浏览器报告的状态;“浏览器标记冻结”可与已记录标题同时显示,不能据此判定页面无法交互。浏览器没有提供的状态显示为未知,不推断为加载完成。诊断只用于当前展示,不单独保存或记录网页 URL。 ## 本机桥接与排障 @@ -140,7 +143,8 @@ Python 集成测试需要在临时目录创建 Unix socket,不操作真实浏 | Native 桥接与 Alfred 列表 | Chrome 用户目录注册成功;Alfred 搜索可以显示演示网页 | | DOM 标题与控制页 | 动态标题由 Alpha 更新为包含方括号和中文的标题;点击 URL 返回同一标签;所选单项 Markdown 在真实文本框粘贴准确;关闭演示标签后列表由 1 项变为 0 项 | | 前台冻结标记兼容 | 已在真实网页复现 active、complete、可交互而 frozen=true;更新扩展后,激活原标签即自动采集 DOM 标题,控制页显示已记录,API 仍报告 frozen=true;未刷新网页或点击手动同步 | -| 手动同步 | 测试页转到后台后修改 DOM title,控制页先保持旧标题;点击同步后更新为新标题,测试页未被激活、标签数量未增加。已释放页面的跳过原因和数量已实测,Alfred 共用缓存数量一致 | +| 标题退化保护 | 本地演示页从 Dia Tabs Demo Alpha 改为主机名 127.0.0.1 时,控制页和桥接均保留原标题;随后改为 Alpha [changed] 中文时正常更新。关闭演示标签后筛选结果变为 0 项 | +| 手动同步 | 测试页转到后台后修改 DOM title,控制页先保持旧标题;点击同步后更新为新标题,测试页未被激活、标签数量未增加。当前 profile 的 21 页同步实测:2 页成功(均为后台且标记 frozen)、3 页超时、14 页额度不足、2 页已释放,未切换标签;Alfred 共用缓存数量一致 | | 待验证 | 手动重命名后动态标题、跨窗口/最小化、浏览器退出后的清理、Alfred 回车实际切换及 Cmd/Alt+回车实际复制 | Alfred 修饰键复制已通过参数和 `pbcopy` 的离线测试;组合键的实机交互尚未确认,不能据此声称验收完成。 diff --git a/docs/specs/2026-11-01-dia-tabs-design.md b/docs/specs/2026-11-01-dia-tabs-design.md index 259c68f..f1de74b 100644 --- a/docs/specs/2026-11-01-dia-tabs-design.md +++ b/docs/specs/2026-11-01-dia-tabs-design.md @@ -50,6 +50,7 @@ | 重复上报相同 URL | 只触发满足条件的重新采集,不作废已有标题 | | 同 URL 标签 frozen/discarded/unloaded,可能同时上报 loading | 保留已采集标题,仍增加采集版本;恢复后也拒绝此前的在途结果 | | DOM title 变化 | active 时由 `onUpdated` 的 title 事件或顶层 MutationObserver 通知触发重新读取 DOM | +| 同 URL 的具体标题退化成域名或完整域名段 | 所有状态与版本检查通过后仍保留旧标题;手动同步计入 title_regression 跳过原因 | | SPA 改 URL | 作废旧标题,active 且 complete 时重新读取;后续 title 事件继续更新 | | 页面为空标题 | 显示“无标题”;Markdown 使用 URL 作为链接文本,不回退到 tabs.title | | 受限页面或执行脚本失败 | 保留 URL,显示“无法读取页面标题”;不得伪装采集成功 | @@ -61,6 +62,8 @@ 标题采集不承诺存在一个永远稳定的标题。页面可以持续修改 title;记录的是最后一次满足条件时读到的 DOM title,不能严格归因到某次用户操作。顶层 MutationObserver 只关注 title 的增删和内容变化,首次变化立即发送无标题 payload 的通知,150 毫秒窗口内的后续变化在窗口结束时合并通知;持续变化不会无限等待稳定,style/meta 变化不触发通知。新版本采样会替换已有页面的 V1 observer,避免必须重载网页。Dia 手动重命名后动态标题的实机行为仍待验证。 +标题退化保护仅在同 URL 的既有 captured 标题去除首尾空白后非空、且不属于域名标题时生效;新标题必须是有效字符串。域名标题按 trim、转小写、去掉单个 `www.` 前缀和末尾点后比较完整 hostname,或匹配完整 DNS labels 组成的连续片段。单 label 至少 3 个字符、包含 ASCII 字母,不能是最后的 TLD 或 `www`;多 label 至少 2 段。不使用任意字符 substring 或公共后缀推断,IP 与含 punycode 的 IDN 仅比较 URL 原生完整 hostname,不拆段,协议、端口和路径形式不参与匹配。首次域名标题、空标题后的域名采集,以及域名标题升级为具体标题均允许;导航或真实重载清空后不跨页面保护。该规则是启发式,同 URL SPA 合法切回站点名时也可能保留旧值;不保存标题历史。 + ## 状态与并发边界 标题缓存放 `chrome.storage.session`,与浏览器会话同生命周期,跨 service worker 重启保留;不放 `storage.sync` 或磁盘 JSON。sessionId 也放在 session storage。浏览器重新启动、扩展重载或升级后重新采集,避免旧 tabId 复用导致错误匹配。 @@ -69,7 +72,7 @@ 采集前记下 tabId、URL 和版本;异步脚本返回后重新 `tabs.get`,仅在标签仍存在、仍 active、仍 complete、未 discarded/frozen、版本和 URL 均相同、DOM readyState 为 complete 时接受。前台探测可忽略仍为 true 的 frozen,但必须额外检查 DOM visible、窗口仍明确 focused=true;激活或窗口焦点变化同步推进探测版本,即使切走再返回也拒绝此前的探测结果。极快切走可能使自动采样来不及完成,下次激活再补采,不保证每次短暂访问都采到变化后的标题。显式手动同步放宽 active/frozen 限制,不检查窗口焦点、DOM visible 或前台探测版本,并在注入前再次读取实时标签状态;导航、关闭、实际生命周期事件和更晚采样校验不变。手动读取缺少有效字符串 title 时计为“无法读取”,但保留同 URL 的既有 captured 记录。导航与生命周期暂停事件必须先同步使版本失效,再执行异步工作。关闭后的延迟响应不可重新插回缓存。缓存写入应串行或按 tab key 写,避免两个异步采集互相覆盖整张表。 -手动同步最多占用 3 个实际未结束的脚本读取,与前台探测合计最多 4 个,为自动前台探测保留一个槽。已有 3 个受限执行未结束时,其余手动读取计为 capacity,不再注入;不保证每个标签都会实际尝试或成功。同一 tabId 已有受限执行时不再注入,去重和容量检查不推进采样版本;重复手动同步请求复用同一批次。普通非 frozen 自动采集保持原有语义。受限读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,晚到结果只释放额度,不更新标题。导航、关闭不提前释放执行额度,初始化中的探测也不无限阻塞。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。同步响应另含 `skipReasons` 原因计数:`discarded`、`frozen`、`loading`、`unloaded`、`unknown_status`、`stale`、`closed`、`timeout`、`capacity`;已释放优先于 status 判定,frozen 原因保留兼容。仅返回本轮汇总,不持久化诊断。 +手动同步最多占用 3 个实际未结束的脚本读取,与前台探测合计最多 4 个,为自动前台探测保留一个槽。已有 3 个受限执行未结束时,其余手动读取计为 capacity,不再注入;不保证每个标签都会实际尝试或成功。同一 tabId 已有受限执行时不再注入,去重和容量检查不推进采样版本;重复手动同步请求复用同一批次。普通非 frozen 自动采集保持原有语义。受限读取超过 5 秒结束等待并计为跳过;浏览器尚未结束的脚本继续占用额度,直到实际成功或失败,晚到结果只释放额度,不更新标题。导航、关闭不提前释放执行额度,初始化中的探测也不无限阻塞。成功、无法读取、跳过数量来自本轮实际采集结果,不从历史缓存推算。同步响应另含 `skipReasons` 原因计数:`discarded`、`frozen`、`loading`、`unloaded`、`unknown_status`、`stale`、`closed`、`timeout`、`capacity`、`title_regression`;title_regression 保留缓存并显示“标题退化,已保留”,已释放优先于 status 判定,frozen 原因保留兼容。仅返回本轮汇总,不持久化诊断。 service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录;初始化期间暂存的 URL、加载和生命周期事件随后按顺序应用,不包含 title。已关闭或被替换的标签不恢复;被早期事件作废的查询快照不用于初次采样,后续事件按实时状态补采。不能只依赖 `onStartup`:休眠后重启 worker 同样要初始化。 diff --git a/plugins/dia_tabs/extension/control.js b/plugins/dia_tabs/extension/control.js index 0c79d25..6139af8 100644 --- a/plugins/dia_tabs/extension/control.js +++ b/plugins/dia_tabs/extension/control.js @@ -142,7 +142,7 @@ async function syncOpenTabs() { const result = await chrome.runtime.sendMessage({type: 'dia-tabs-sync'}); if (!result?.ok) throw Error('sync failed'); await refresh(); - const labels = {discarded: '已释放', frozen: '浏览器标记冻结', loading: '加载中', unloaded: '尚未加载', unknown_status: '状态未知', stale: '页面已变化', closed: '已关闭', timeout: '读取超时', capacity: '先前读取尚未结束'}; + const labels = {discarded: '已释放', frozen: '浏览器标记冻结', loading: '加载中', unloaded: '尚未加载', unknown_status: '状态未知', stale: '页面已变化', closed: '已关闭', timeout: '读取超时', capacity: '先前读取尚未结束', title_regression: '标题退化,已保留'}; const reasons = Object.entries(result.skipReasons || {}).map(([reason, count]) => `${labels[reason] || '其他'} ${count} 个`).join(','); notify(`同步完成:成功 ${result.captured} 个,无法读取 ${result.unavailable} 个,跳过 ${result.skipped} 个${reasons ? `(${reasons})` : ''}。`, 'success'); } catch { notify('同步失败,请稍后重试。', 'error'); } diff --git a/plugins/dia_tabs/extension/state.js b/plugins/dia_tabs/extension/state.js index 41a76f5..61e76a8 100644 --- a/plugins/dia_tabs/extension/state.js +++ b/plugins/dia_tabs/extension/state.js @@ -1,5 +1,22 @@ const isWeb = tab => !tab.incognito && /^https?:\/\//i.test(tab.url || ''); +function isDomainTitle(title, url) { + let hostname; + try { hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, '').replace(/\.$/, ''); } + catch { return false; } + const value = title.trim().toLowerCase().replace(/^www\./, '').replace(/\.$/, ''); + if (!value) return false; + if (value === hostname) return true; + // IP and IDN hosts only match as a whole; DNS fragments use complete labels. + if (hostname.includes(':') || /^\d+(\.\d+){3}$/.test(hostname) || /(^|\.)xn--/.test(hostname)) return false; + const labels = hostname.split('.'), parts = value.split('.'); + if (parts.length === 1) { + if (value.length < 3 || !/[a-z]/.test(value) || value === 'www') return false; + return labels.slice(0, -1).includes(value); + } + return labels.some((_, index) => labels.slice(index, index + parts.length).join('.') === value); +} + // Only this injected function reads page content. The observer sends a signal; // the worker checks active/complete again before each subsequent title read. export function readPageTitle() { @@ -131,6 +148,8 @@ export function createTabService(api) { if (foregroundProbe && (probeEpoch !== epoch || page?.visibilityState !== 'visible' || typeof page?.title !== 'string')) return 'stale'; const record = records.get(tab.id); if (allowBackground && typeof page?.title !== 'string' && record?.url === tab.url && record.titleState === 'captured') return 'unavailable'; + if (record?.url === tab.url && record.titleState === 'captured' && record.title.trim() && typeof page?.title === 'string' + && isDomainTitle(page.title, tab.url) && !isDomainTitle(record.title, tab.url)) return 'title_regression'; records.set(tab.id, {url: tab.url, title: typeof page?.title === 'string' ? page.title : '', titleState: typeof page?.title === 'string' ? 'captured' : 'unavailable'}); persist(); diff --git a/plugins/dia_tabs/tests/background.test.mjs b/plugins/dia_tabs/tests/background.test.mjs index 4b82f2c..150081f 100644 --- a/plugins/dia_tabs/tests/background.test.mjs +++ b/plugins/dia_tabs/tests/background.test.mjs @@ -608,3 +608,68 @@ test('failed manual reads count unavailable without replacing a captured title f await s.idle();assert.equal(executions,1);assert.deepEqual(f.storage.diaTabsRecords[1],saved.diaTabsRecords[1]); }); }); + +test('specific captured titles resist domain replacements in automatic and manual frozen paths', async t => { + for(const mode of ['auto','auto-frozen','manual','manual-frozen']) await t.test(mode,async()=>{ + const url='https://tenant.example.com/',title='Design review notes'; + const f=fake([tab(1,{url,active:false,frozen:mode.endsWith('frozen')})],{diaTabsRecords:{1:{url,title,titleState:'captured'}}}); + f.api.windows.get=async id=>({id,focused:true});const s=service(f.api);await s.ready; + f.api.scripting.executeScript=async()=>[{frameId:0,result:{title:'tenant.example.com',url,readyState:'complete',visibilityState:'visible'}}]; + if(mode.startsWith('auto')) {f.current.set(1,{...f.current.get(1),active:true});await s.refreshTab(1);} + else assert.deepEqual(await s.syncOpenTabs(),{captured:0,unavailable:0,skipped:1,skipReasons:{title_regression:1}}); + await s.idle();assert.equal(f.storage.diaTabsRecords[1].title,title); + }); +}); + +test('domain-title protection matches complete DNS labels and avoids arbitrary fragments, IP and IDN splits', async t => { + const cases=[ + ['tenant.example.com',' TENANT.EXAMPLE.COM. ',true],['tenant.example.com','www.tenant.example.com',true], + ['tenant.example.com','tenant',true],['tenant.example.com','example',true], + ['tenant.example.com','tenant.example',true],['tenant.example.com','example.com',true], + ['www.tenant.example.com.','tenant.example.com',true], + ['sometime.tenant.example.com','Time',false],['tenant.example.com','ten',false], + ['tenant.example.com','ample',false],['tenant.example.com','com',false], + ['ww.tenant.example.com','ww',false],['123.tenant.example.com','123',false], + ['tenant.www.example.com','www',false],['tenant.example.com','tenant.com',false], + ['tenant.example.com','https://tenant.example.com',false],['tenant.example.com','tenant.example.com:443',false], + ['tenant.example.com','tenant.example.com/path',false], + ['127.0.0.1','127.0.0.1',true],['127.0.0.1','127',false],['127.0.0.1','0.0.1',false], + ['[2001:db8::1]','[2001:db8::1]',true],['[2001:db8::1]','db8',false], + ['tenant.xn--fsqu00a.xn--0zwm56d','tenant.xn--fsqu00a.xn--0zwm56d',true], + ['tenant.xn--fsqu00a.xn--0zwm56d','tenant',false], + ['tenant.xn--fsqu00a.xn--0zwm56d','xn--fsqu00a.xn--0zwm56d',false], + ]; + for(const [host,next,blocked]of cases) await t.test(`${host} -> ${next}`,async()=>{ + const url=`https://${host}/`,title='Project overview'; + const f=fake([tab(1,{url,active:false})],{diaTabsRecords:{1:{url,title,titleState:'captured'}}});const s=service(f.api);await s.ready; + f.api.scripting.executeScript=async()=>[{frameId:0,result:{title:next,url,readyState:'complete'}}]; + assert.deepEqual(await s.syncOpenTabs(),blocked?{captured:0,unavailable:0,skipped:1,skipReasons:{title_regression:1}}:{captured:1,unavailable:0,skipped:0,skipReasons:{}}); + await s.idle();assert.equal(f.storage.diaTabsRecords[1].title,blocked?title:next); + }); +}); + +test('initial domain titles, empty captured titles and upgrades to specific titles remain allowed', async t => { + for(const [before,next]of [[null,'tenant.example.com'],['','tenant.example.com'],['tenant.example.com','Project notes'],['example','tenant.example.com']]) await t.test(`${before} -> ${next}`,async()=>{ + const url='https://tenant.example.com/'; + const f=fake([tab(1,{url,active:false})],before===null?{}:{diaTabsRecords:{1:{url,title:before,titleState:'captured'}}}); + const s=service(f.api);await s.ready; + f.api.scripting.executeScript=async()=>[{frameId:0,result:{title:next,url,readyState:'complete'}}]; + assert.deepEqual(await s.syncOpenTabs(),{captured:1,unavailable:0,skipped:0,skipReasons:{}}); + await s.idle();assert.equal(f.storage.diaTabsRecords[1].title,next); + }); +}); + +test('domain-title protection does not cross a real URL navigation or same-URL reload', async t => { + for(const reload of [false,true]) await t.test(`reload=${reload}`,async()=>{ + const url='https://tenant.example.com/'; + const f=fake([tab(1,{url,active:false})],{diaTabsRecords:{1:{url,title:'Project notes',titleState:'captured'}}}); + const s=service(f.api);await s.ready; + f.current.set(1,tab(1,{url:reload?url:`${url}next`,active:false,status:'loading'})); + f.api.tabs.onUpdated.emit(1,reload?{status:'loading'}:{url:f.current.get(1).url,status:'loading'},f.current.get(1)); + assert.equal((await s.list())[0].titleState,'pending'); + f.current.set(1,{...f.current.get(1),status:'complete'});f.api.tabs.onUpdated.emit(1,{status:'complete'},f.current.get(1)); + f.api.scripting.executeScript=async()=>[{frameId:0,result:{title:'tenant.example.com',url:f.current.get(1).url,readyState:'complete'}}]; + assert.deepEqual(await s.syncOpenTabs(),{captured:1,unavailable:0,skipped:0,skipReasons:{}}); + await s.idle();assert.equal(f.storage.diaTabsRecords[1].title,'tenant.example.com'); + }); +}); diff --git a/plugins/dia_tabs/tests/control.test.mjs b/plugins/dia_tabs/tests/control.test.mjs index 5d9cb16..8fa3461 100644 --- a/plugins/dia_tabs/tests/control.test.mjs +++ b/plugins/dia_tabs/tests/control.test.mjs @@ -134,12 +134,13 @@ test('sync button shows counts, preserves selection, ignores repeat clicks and r assert.equal(typeof elements.sync?.listeners.click,'function'); const pending=elements.sync.listeners.click();assert.equal(elements.sync.disabled,true);assert.equal(elements['sync-label'].textContent,'正在同步…'); elements.sync.listeners.click();assert.equal(syncCalls,1); - rows=[{...rows[0],title:'New DOM'}];resolveSync({ok:true,captured:1,unavailable:2,skipped:3,skipReasons:{discarded:1,frozen:1,unknown_status:1}});await pending; + rows=[{...rows[0],title:'New DOM'}];resolveSync({ok:true,captured:1,unavailable:2,skipped:4,skipReasons:{discarded:1,frozen:1,unknown_status:1,title_regression:1}});await pending; assert.equal(elements.sync.disabled,false);assert.equal(elements['sync-label'].textContent,'同步已打开标签'); assert.equal(elements.tabs.children[0].children[0].children[0].checked,true); assert.equal(elements.tabs.children[0].children[1].children[0].textContent,'New DOM'); - assert.match(elements.notice.textContent,/成功 1.*无法读取 2.*跳过 3.*已释放 1.*状态未知 1/); + assert.match(elements.notice.textContent,/成功 1.*无法读取 2.*跳过 4.*已释放 1.*状态未知 1/); assert.match(elements.notice.textContent,/浏览器标记冻结 1 个/); + assert.match(elements.notice.textContent,/标题退化,已保留 1 个/); for (const [status, frozen, expected] of [['complete',true,'浏览器标记冻结'],['loading',false,'加载中'],['unloaded',false,'尚未加载'],['unknown',false,'状态未知']]) { rows=[{...rows[0],discarded:false,frozen,status}]; await elements.refresh.listeners.click(); diff --git a/plugins/dia_tabs/tests/fixtures/alpha.html b/plugins/dia_tabs/tests/fixtures/alpha.html index 39f4550..3ce13aa 100644 --- a/plugins/dia_tabs/tests/fixtures/alpha.html +++ b/plugins/dia_tabs/tests/fixtures/alpha.html @@ -7,6 +7,7 @@

Dia Tabs · Alpha 公开演示页

初始 document.title 为 Dia Tabs Demo Alpha。可在 Dia 手动重命名标签,随后测试扩展仍读取网页标题。

+

点击“离开本页时修改标题”后切到扩展控制页,离开约 1 秒后再点同步,标题应变为 Dia Tabs Background Sync Proof。

当前网页标题:Dia Tabs Demo Alpha

打开 Beta 新标签打开同 URL 的 Alpha 副本同 URL 重新加载

@@ -27,5 +28,6 @@

Dia Tabs · Alpha 公开演示页

function setTitle(value) { document.title=value; document.getElementById('current').textContent='当前网页标题:'+value; } document.getElementById('change').addEventListener('click',()=>setTitle('Alpha [changed] 中文')); document.getElementById('reset').addEventListener('click',()=>setTitle('Dia Tabs Demo Alpha')); +document.getElementById('domain-title').addEventListener('click',()=>setTitle(location.hostname));