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..3b34d96 --- /dev/null +++ b/docs/plugins/dia_tabs.md @@ -0,0 +1,150 @@ +# 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 | 激活该已有标签及其窗口 | +| 扩展控制页 | 同步已打开标签 | 读取当前 profile 已加载网页的原始标题,包含后台标签;显示本轮成功、无法读取和跳过数量 | +| 扩展控制页 | 单击行、Cmd/Ctrl 切换、Shift 连选 | 选择当前列表中的一项或多项 | +| 扩展控制页 | 复制所选 URL / Markdown | 按列表顺序输出,一项一行;同 URL 的多个标签保留多项 | +| Alfred | `dia list` | 汇总已连接扩展的各 profile 当前网页 | +| Alfred | `dia list 关键词` | 按原始标题和 URL 搜索,不区分大小写 | +| Alfred | 回车 | 精确激活这条结果对应的标签 | +| Alfred | Cmd+回车 / Alt+回车 | 分别复制 URL / Markdown | + +控制页只显示当前 profile。Alfred 每条结果附连接短标识以区分 profile;只展示能够实时响应的连接,不保存历史列表。 + +控制页的搜索、复制和结果反馈集中在列表上方,长列表的表头保持可见。标题和 URL 以单行省略显示,悬停可查看完整内容;复制仍使用完整原始值。状态标签同时显示标题采集结果、页面当前状态和窗口编号,浅色与系统深色外观均可使用。 + +## 安装 + +先构建安装文件,再将运行文件安装到稳定目录。不要从临时 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。点击工具栏扩展按钮打开控制页,检查“Alfred 连接就绪”;必要时点击“重新连接”。连接失败时,状态下方会显示后端返回的错误详情,恢复后自动清除;连接不可用时,控制页列表和复制仍可独立使用。 + +默认 `--prefix` 为上述稳定目录。自定义时路径必须足够短,完整 socket 路径(UTF-8 字节)小于 104 字节;安装器会在写入前检查。使用自定义 prefix 时,同时指定 `--workflow-dir` 以写入正确 runtime 路径,或在 Alfred Workflow 的变量 `DIA_TABS_HOME` 中填写该绝对路径。 + +## 标题何时更新 + +自动采集只在标签是所在窗口的 active 标签、且加载完成时读取顶层网页 DOM;首次安装不会遍历访问所有后台网页。多个窗口各自选中的标签都可以采集。缓存存于 `chrome.storage.session`,浏览器重启或扩展重载后重新采集,不建立网页历史数据库。 + +点击“同步已打开标签”会尝试读取本 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,返回后仍满足前台条件,才接受原始标题。失焦、切走或探测失败保留已有标题;真正导航仍清空。窗口重新获得焦点时补采当前标签,自动采集不探测后台冻结页面;显式手动同步可尝试读取。 + +| 状态 | 列表显示 | 复制 Markdown 的链接文字 | +|---|---|---| +| 已采集原始标题 | 网页原始标题 | 原始标题 | +| 还未访问 | 待访问 | URL | +| 网页原始标题为空 | 无标题 | URL | +| 受限页面或读取失败 | 无法读取页面标题 | URL | + +| 页面变化 | 标题处理 | +|---|---| +| URL 真正改变或正常重载(含同 URL 重载) | 立即作废旧标题;后台导航同样清空 | +| 重复上报相同 URL、浏览器自定义标签名变化 | 不直接清空或使用浏览器标签名;active 且加载完成时重新读取 DOM | +| 同 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。 + +## 本机桥接与排障 + +每个扩展 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 项 | +| 前台冻结标记兼容 | 已在真实网页复现 active、complete、可交互而 frozen=true;更新扩展后,激活原标签即自动采集 DOM 标题,控制页显示已记录,API 仍报告 frozen=true;未刷新网页或点击手动同步 | +| 标题退化保护 | 本地演示页从 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/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..f1de74b --- /dev/null +++ b/docs/specs/2026-11-01-dia-tabs-design.md @@ -0,0 +1,193 @@ +# 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,未 discarded/frozen | 在顶层 frame 读取 `document.title`、`location.href`、`document.readyState` | +| frozen=true,但 active、complete、未 discarded,且窗口明确 focused=true | 允许有界前台探测;仅接受同 URL、DOM complete/visible 且返回后仍在前台的标题 | +| 初次启动、`onActivated`、窗口获得焦点、`onUpdated` 的 complete/title 变化,或 frozen/discarded 变为 false | 满足上述条件时重新采集,不使用事件中的 title 值;后台恢复不自动采集 | +| 非 active 标签 | 不自动采集;保留其已采集且仍对应当前 URL 的标题 | +| 点击“同步已打开标签” | 尝试读取当前 profile HTTP/HTTPS 已加载网页标题,包含后台及标记 frozen 的标签,不要求 active、focused 或 DOM visible;不主动切换、加载或重载页面 | +| 手动同步遇到 loading/discarded/unloaded | 跳过,保持页面状态;标记 frozen 且 complete 的页面仍尝试读取,单次读取超过 5 秒也跳过 | +| URL 真正改变或正常重载(含同 URL 重载) | 立即作废旧标题,增加该 tab 的采集版本;禁止旧标题与新 URL 配对 | +| 重复上报相同 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,显示“无法读取页面标题”;不得伪装采集成功 | +| 标签关闭或被替换 | 删除旧 tabId 缓存;新 tabId 按新标签处理 | + +控制页和 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 的后台页面,接受同 URL、DOM complete 的标题;若页面真正冻结而不执行脚本,按超时和额度规则处理。控制页使用“浏览器标记冻结”,保留报告值,不将其解释为页面一定停止运行。 + +标题采集不承诺存在一个永远稳定的标题。页面可以持续修改 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 复用导致错误匹配。 + +记录至少包含 `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/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`、`title_regression`;title_regression 保留缓存并显示“标题退化,已保留”,已释放优先于 status 判定,frozen 原因保留兼容。仅返回本轮汇总,不持久化诊断。 + +service worker 的事件监听器在模块顶层同步注册。初始化载入 session 后,重新查询当前标签并清理不存在或 URL 不符的记录;初始化期间暂存的 URL、加载和生命周期事件随后按顺序应用,不包含 title。已关闭或被替换的标签不恢复;被早期事件作废的查询快照不用于初次采样,后续事件按实时状态补采。不能只依赖 `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 请求共用此协议。 + +手动同步使用控制页专属 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":[]} +{"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}`。列表另附实时 `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 请求。 + +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..417e9f4 --- /dev/null +++ b/plugins/dia_tabs/extension/background.js @@ -0,0 +1,78 @@ +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-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}); + 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..c8dc905 --- /dev/null +++ b/plugins/dia_tabs/extension/control.css @@ -0,0 +1,442 @@ +:root { + color-scheme: light dark; + --background: #f3f5f8; + --surface: #ffffff; + --surface-subtle: #f8f9fb; + --ink: #202733; + --muted: #697586; + --subtle: #8a95a5; + --border: #e1e6ec; + --border-strong: #cdd5df; + --accent: #356bc7; + --accent-hover: #2b5cae; + --accent-soft: #edf3fd; + --accent-ring: rgba(53, 107, 199, .22); + --on-accent: #ffffff; + --selected: #eef4fd; + --selected-hover: #e6effc; + --success: #287a57; + --success-soft: #eaf5ef; + --warning: #9a641f; + --warning-soft: #fbf2e4; + --danger: #b34747; + --danger-soft: #fbeded; + --frozen: #735ca6; + --frozen-soft: #f1edf8; + --shadow: 0 1px 2px rgba(24, 34, 48, .04), 0 8px 24px rgba(24, 34, 48, .035); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 320px; + background: var(--background); + color: var(--ink); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +button, input { font: inherit; } +button, a, input { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: default; } + +.app-shell { + max-width: 1504px; + margin: 0 auto; + padding: 34px 30px 24px; +} + +.app-header, .actions, .toolbar, .table-meta, .bridge { + display: flex; + align-items: center; +} + +.app-header { + justify-content: space-between; + gap: 28px; + padding: 0 2px 24px; +} + +.eyebrow { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 9px; + color: var(--muted); + font-size: 11px; + font-weight: 650; + letter-spacing: .13em; +} + +.product-mark { + width: 15px; + height: 15px; + border: 1.5px solid var(--accent); + border-radius: 4px; + box-shadow: inset 3px 0 0 var(--accent-soft); +} + +h1 { + margin: 0; + font-size: 28px; + font-weight: 680; + line-height: 1.25; + letter-spacing: -.035em; +} + +.intro { margin: 8px 0 0; color: var(--muted); line-height: 1.6; } +.actions { gap: 10px; } +.header-actions { flex-shrink: 0; } + +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 36px; + padding: 8px 13px; + border: 1px solid transparent; + border-radius: 8px; + font-size: 13px; + font-weight: 560; + line-height: 1.35; + white-space: nowrap; + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, color .15s ease; +} + +button svg, .search-control svg { + width: 17px; + height: 17px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 1.55; + stroke-linecap: round; + stroke-linejoin: round; +} + +.primary-button { + min-height: 40px; + padding-inline: 16px; + background: var(--accent); + color: var(--on-accent); + border-color: var(--accent); + box-shadow: 0 1px 2px rgba(25, 55, 105, .14); +} + +.primary-button:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); } +.primary-button:active:not(:disabled) { transform: translateY(1px); } + +.secondary-button { + background: var(--surface); + color: var(--ink); + border-color: var(--border-strong); + box-shadow: 0 1px 1px rgba(24, 34, 48, .025); +} + +.secondary-button:hover:not(:disabled) { background: var(--surface-subtle); border-color: var(--subtle); } +.header-actions .secondary-button { min-height: 40px; } + +button:disabled { + opacity: .46; + box-shadow: none; +} + +button:focus-visible, a:focus-visible, input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.text-button { + min-height: 30px; + padding: 4px 7px; + background: transparent; + color: var(--accent); + font-size: 12px; +} + +.text-button:hover { background: var(--accent-soft); } + +.bridge { + flex-wrap: wrap; + gap: 10px; + min-height: 42px; + margin-bottom: 18px; + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: color-mix(in srgb, var(--surface) 72%, transparent); + color: var(--muted); + font-size: 12px; +} + +.connection-status { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--ink); + font-weight: 520; +} + +.connection-status::before { + content: ""; + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--subtle); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--subtle) 12%, transparent); +} + +.connection-status[data-state="connected"]::before { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); } +.connection-status[data-state="connecting"]::before { background: var(--warning); box-shadow: 0 0 0 3px var(--warning-soft); } +.bridge-note { margin-right: auto; } +.bridge-detail { flex-basis: 100%; margin-left: 14px; overflow-wrap: anywhere; line-height: 1.55; } +.muted { color: var(--muted); } + +.workspace { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 13px; + background: var(--surface); + box-shadow: var(--shadow); +} + +.toolbar { + justify-content: space-between; + align-items: flex-end; + gap: 20px; + padding: 18px 20px 16px; +} + +.search { display: block; width: min(430px, 100%); } +.field-label { display: block; margin: 0 0 7px 1px; color: var(--muted); font-size: 12px; font-weight: 550; } +.search-control { position: relative; display: block; } +.search-control svg { position: absolute; top: 50%; left: 12px; width: 16px; height: 16px; color: var(--subtle); transform: translateY(-50%); pointer-events: none; } + +input[type="search"] { + width: 100%; + height: 38px; + padding: 8px 12px 8px 36px; + border: 1px solid var(--border-strong); + border-radius: 8px; + outline: none; + background: var(--surface-subtle); + color: var(--ink); + font-size: 13px; + transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease; +} + +input[type="search"]::placeholder { color: var(--subtle); } +input[type="search"]:hover { border-color: var(--subtle); } +input[type="search"]:focus { border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-ring); } +input[type="search"]::-webkit-search-cancel-button { cursor: pointer; } + +#notice { + margin: 0 20px 14px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-subtle); + color: var(--muted); + font-size: 12px; + line-height: 1.55; +} + +#notice:empty { display: none; } +#notice[data-tone="success"] { border-color: color-mix(in srgb, var(--success) 24%, var(--border)); background: var(--success-soft); color: var(--success); } +#notice[data-tone="error"] { border-color: color-mix(in srgb, var(--danger) 25%, var(--border)); background: var(--danger-soft); color: var(--danger); } +#notice[data-tone="progress"] { border-color: color-mix(in srgb, var(--accent) 20%, var(--border)); background: var(--accent-soft); color: var(--accent); } + +.table-meta { + justify-content: space-between; + gap: 16px; + padding: 0 20px 12px; + color: var(--muted); + font-size: 12px; +} + +.count { color: var(--ink); font-weight: 520; } +.selection-hint { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } +kbd { padding: 1px 5px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-subtle); color: var(--muted); font: inherit; font-size: 11px; } + +.table-wrap { + max-height: calc(100vh - 310px); + min-height: 180px; + overflow: auto; + border-top: 1px solid var(--border); + scrollbar-color: var(--border-strong) transparent; + scrollbar-width: thin; +} + +table { width: 100%; min-width: 820px; border-collapse: separate; border-spacing: 0; table-layout: fixed; } + +th { + position: sticky; + top: 0; + z-index: 2; + height: 38px; + padding: 9px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface-subtle); + color: var(--muted); + font-size: 11px; + font-weight: 600; + letter-spacing: .025em; + text-align: left; +} + +td { + height: 58px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +tbody tr { cursor: pointer; transition: background-color .12s ease; } +tbody tr:hover { background: var(--surface-subtle); } +tbody tr.selected { background: var(--selected); } +tbody tr.selected:hover { background: var(--selected-hover); } +tbody tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--accent); } +tbody tr:last-child td { border-bottom: 0; } + +.select-col { width: 48px; padding-inline: 16px 8px; } +.state-col { width: 222px; } +.copy-col { width: 86px; text-align: center; } +td:last-child { text-align: center; } + +input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + vertical-align: middle; + accent-color: var(--accent); + cursor: pointer; +} + +.title-button, .url { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.6; + text-align: left; +} + +.title-button { + min-height: 25px; + padding: 1px 0; + border: 0; + border-radius: 3px; + background: transparent; + color: var(--ink); + font-size: 13px; + font-weight: 580; +} + +.title-button:hover { color: var(--accent); } +.title-button.is-placeholder { color: var(--muted); font-weight: 450; } +.url { color: var(--muted); font-size: 12px; text-decoration: none; } +.url:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } + +.state-cell { padding-block: 8px; } +.state-line { display: flex; align-items: center; gap: 6px; min-width: 0; } +.state-line + .state-line { margin-top: 5px; } + +.state-badge { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + padding: 2px 7px; + border-radius: 5px; + background: var(--surface-subtle); + color: var(--muted); + font-size: 11px; + font-weight: 520; + line-height: 1.35; + white-space: nowrap; +} + +.state-badge::before { content: ""; width: 5px; height: 5px; flex: 0 0 auto; border-radius: 50%; background: currentColor; } +.state-badge.captured { background: var(--success-soft); color: var(--success); } +.state-badge.unavailable { background: var(--warning-soft); color: var(--warning); } +.state-badge.current { background: var(--accent-soft); color: var(--accent); } +.state-badge.discarded { background: var(--warning-soft); color: var(--warning); } +.state-badge.frozen { background: var(--frozen-soft); color: var(--frozen); } +.state-badge.loading { background: var(--accent-soft); color: var(--accent); } +.state-secondary .state-badge { padding-inline: 0; background: transparent; } +.window-label { color: var(--muted); font-size: 11px; white-space: nowrap; } + +.copy-button { min-height: 30px; padding: 5px 10px; border-color: transparent; background: transparent; color: var(--muted); font-size: 12px; box-shadow: none; } +.copy-button:hover { border-color: var(--border-strong); background: var(--surface); color: var(--accent); } +tr.selected .copy-button:hover { border-color: var(--border-strong); } + +#empty { margin: 0; padding: 54px 24px; color: var(--ink); text-align: center; } +#empty span { display: block; margin-top: 7px; color: var(--muted); font-size: 12px; } +.footnote { margin: 13px 4px 0; color: var(--muted); font-size: 11px; line-height: 1.65; } + +#sync[aria-busy="true"] svg { animation: spin .9s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +@media (prefers-color-scheme: dark) { + :root { + --background: #11151b; + --surface: #191f27; + --surface-subtle: #202730; + --ink: #e9edf3; + --muted: #a2adbc; + --subtle: #7f8b9b; + --border: #2b3440; + --border-strong: #3a4654; + --accent: #8cb2f4; + --accent-hover: #a2c1f7; + --accent-soft: #23344d; + --accent-ring: rgba(140, 178, 244, .2); + --on-accent: #ffffff; + --selected: #223249; + --selected-hover: #273a54; + --success: #79c7a2; + --success-soft: #20372f; + --warning: #ddb175; + --warning-soft: #3a3023; + --danger: #ed9a9a; + --danger-soft: #3c272b; + --frozen: #c0ace7; + --frozen-soft: #312a40; + --shadow: 0 1px 2px rgba(0, 0, 0, .16), 0 10px 28px rgba(0, 0, 0, .1); + } + + .primary-button { background: #3f6cba; border-color: #3f6cba; color: #fff; } + .primary-button:hover:not(:disabled) { background: #4a74be; border-color: #4a74be; } +} + +@media (max-width: 760px) { + .app-shell { padding: 24px 18px 20px; } + .app-header { align-items: flex-start; gap: 18px; } + h1 { font-size: 25px; } + .intro { font-size: 13px; } + .bridge { flex-wrap: wrap; } + .bridge-note { order: 3; width: 100%; margin: -2px 0 1px 14px; } + .toolbar { align-items: stretch; flex-direction: column; gap: 13px; } + .search { width: 100%; } + .copy-actions { justify-content: flex-end; } + .table-wrap { max-height: calc(100vh - 360px); } +} + +@media (max-width: 540px) { + .app-header { flex-direction: column; } + .header-actions { width: 100%; } + .header-actions button { flex: 1; } + .table-meta { align-items: flex-start; flex-direction: column; gap: 8px; } + .selection-hint { white-space: normal; flex-wrap: wrap; } + .table-wrap { max-height: calc(100vh - 390px); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } +} diff --git a/plugins/dia_tabs/extension/control.html b/plugins/dia_tabs/extension/control.html new file mode 100644 index 0000000..204a93a --- /dev/null +++ b/plugins/dia_tabs/extension/control.html @@ -0,0 +1,73 @@ + + + + + + Dia 标签管理 + + + +
+
+
+

DIA TABS

+

标签管理

+

查找、切换并复制当前浏览器中的网页标签。

+
+
+ + +
+
+ +
+ 正在检查 Alfred 连接… + 连接供 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..6139af8 --- /dev/null +++ b/plugins/dia_tabs/extension/control.js @@ -0,0 +1,176 @@ +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, syncing = false; + +function visibleTabs() { + const query = byId('search').value.trim().toLocaleLowerCase(); + 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); + 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) { + notify(response?.message || '无法切换标签,请刷新列表。', 'error'); + 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' ? '待访问' : '无法读取页面标题'; + 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; + 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.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-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 = '复制'; + 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); + } + 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); + 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() { + if (loading) { refreshQueued = true; return; } + const preserveNotice = syncing; + 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 { if (!preserveNotice && !syncing) notify('无法读取标签列表,请重新加载扩展后刷新本页。', 'error'); } + finally { + loading = false; byId('refresh').disabled = false; + if (refreshQueued) { refreshQueued = false; refresh(); } + } + await bridge(); +} + +async function syncOpenTabs() { + if (syncing) return; + syncing = true; + byId('sync').disabled = true; + 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: '先前读取尚未结束', 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'); } + finally { + syncing = false; + byId('sync').disabled = false; + byId('sync').setAttribute('aria-busy', 'false'); + byId('sync-label').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'))); +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..61e76a8 --- /dev/null +++ b/plugins/dia_tabs/extension/state.js @@ -0,0 +1,276 @@ +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() { + const key = '__lifeToolsDiaTitleObserverV2'; + if (!globalThis[key] && document.head) { + 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) return; + if (timer) { dirty = true; return; } + chrome.runtime.sendMessage({type: 'dia-tabs-title-changed'}).catch(() => {}); + timer = setTimeout(() => { + timer = null; + if (dirty) { + dirty = false; + 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, 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 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), + })); + writeTail.catch(() => {}); + }; + const track = promise => { + tasks.add(promise); + promise.catch(() => {}).finally(() => tasks.delete(promise)); + return promise; + }; + const invalidate = (id, url, preserveTitle = false) => { + versions.set(id, (versions.get(id) || 0) + 1); + samples.set(id, (samples.get(id) || 0) + 1); + 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'); + }; + // 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'; + 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'; + // 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; + // 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 && !allowBackground && !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; + } + // 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 >= readLimit)) 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 && !allowBackground && !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'; + 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(); + return records.get(tab.id).titleState; + }; + 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'}); + } + // 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.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') + || 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}) => { 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) => { + 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(tab => capture(tab))); + 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, + 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 = () => { + if (syncPromise) return syncPromise; + syncPromise = (async () => { + await ready; + 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(3, tabs.length)}, async () => { + while (next < tabs.length) { + const result = await capture(tabs[next++], {allowBackground: true}); + if (result === 'captured' || result === 'unavailable') counts[result]++; + else { counts.skipped++; counts.skipReasons[result] = (counts.skipReasons[result] || 0) + 1; } + } + })); + 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}); + 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, 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/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..150081f --- /dev/null +++ b/plugins/dia_tabs/tests/background.test.mjs @@ -0,0 +1,675 @@ +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,readPageTitle} = 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(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',visibilityState:'visible'}}];}}, + storage:{session:{async get() {return structuredClone(storage);},async set(value) {Object.assign(storage,structuredClone(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}; +} +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'); +}); + +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,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); + 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 () => { + 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); +}); + +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: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,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,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 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; + 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,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,3);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,skipReasons:{[change==='close'?'closed':'stale']:1}});await s.idle(); + assert.equal(f.storage.diaTabsRecords[1]?.title,change==='close'?undefined:change==='newer'?'Newer DOM':''); + }); +}); + +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;}; + 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:1,unavailable:0,skipped:1,skipReasons:{discarded:1}});assert.equal(scripts,1); +}); + +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(); + await new Promise(resolve=>setImmediate(resolve));t.mock.timers.tick(5000); + 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,'Saved DOM'); +}); + +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,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)); +}); + + +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,unloaded:1}); + assert.equal(f.calls.length,1); +}); + +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'); +}); + +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('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(); + 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(); + 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('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=[];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 () => { + 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'); +}); + +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]); + }); +}); + +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/bridge.test.mjs b/plugins/dia_tabs/tests/bridge.test.mjs new file mode 100644 index 0000000..cb2f8aa --- /dev/null +++ b/plugins/dia_tabs/tests/bridge.test.mjs @@ -0,0 +1,60 @@ +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,service}; +} + +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('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'}; + 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..8fa3461 --- /dev/null +++ b/plugins/dia_tabs/tests/control.test.mjs @@ -0,0 +1,181 @@ +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','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}]; + 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 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); + 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); +}); + +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 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(){}}, + 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)); + 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-label'].textContent,'正在同步…'); + elements.sync.listeners.click();assert.equal(syncCalls,1); + 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.*跳过 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(); + 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,''); +}); diff --git a/plugins/dia_tabs/tests/fixtures/alpha.html b/plugins/dia_tabs/tests/fixtures/alpha.html new file mode 100644 index 0000000..3ce13aa --- /dev/null +++ b/plugins/dia_tabs/tests/fixtures/alpha.html @@ -0,0 +1,33 @@ + + +Dia Tabs Demo Alpha + + +

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/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:-}"