diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0130fc0e..d621f47ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,36 +65,6 @@ jobs: - name: Type-check all packages run: pnpm -r typecheck - # Rust 守护进程(packages/daemon):fmt + clippy -D warnings + 单测,与 TS 侧 - # 的 lint/typecheck/test 同级门禁。cargo 缓存加速重复构建。 - daemon: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - with: - workspaces: packages/daemon - - - name: Format check - run: cargo fmt --check - working-directory: packages/daemon - - # clippy 以 -D warnings 为门禁:任何告警即失败,与 biome lint 同一标准。 - - name: Clippy - run: cargo clippy --all-targets --all-features -- -D warnings - working-directory: packages/daemon - - - name: Tests - run: cargo test - working-directory: packages/daemon - # 离线单测(packages/*/test/*.test.ts)。需要真 QQ 环境的探针脚本在 # packages/*/tools/ 下,不在这里跑 —— 约定见 docs/develop/testing.md。 test: @@ -117,9 +87,9 @@ jobs: run: pnpm install --frozen-lockfile - name: Run unit tests - run: pnpm -r test + run: pnpm test:fail-fast - # 浏览器版打包冒烟(linux-x64):native/ + daemon 随包 → 两个守卫 → + # 浏览器版打包冒烟(linux-x64):native/ 随包 → 两个守卫 → # 启动真实产物走一遍鉴权流程。产物现在按平台/架构各自成包,build-web 冒烟 # 的就是 release web 矩阵里 linux-x64 那一条。 # 守卫防的是同一类回归:Electron-only 的代码混进 web 构建后不会报错, @@ -143,23 +113,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # 鉴权闸 + 端到端路由测试。不需要构建产物,先跑、失败得早。 - - name: Test auth gate - run: pnpm --filter @weq/web test - # build-server 会把 native/// 拷进 dist,而 nt_helper.node # 不入库 —— 与 release 的 web 矩阵一样,打包前先取当前平台那一份。 - name: Fetch nt_helper native artifacts run: node scripts/fetch-native.mjs --require-fresh --verify - # 先打当前平台(linux-x64)的守护进程,build-server 与 smoke 才会把它 - # 视作打包目标的一部分;与 release 的 web 矩阵保持一致。 - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Build daemon binary - run: pnpm run build:daemon - # build 内含 check:check-electron-free(走真实 import 图) # + check-bundle(扫前端产物里残留的自定义协议 URL)。 - name: Build + guards diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a60772fe9..7037d78f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,9 @@ on: permissions: contents: write # 创建 / 上传 Release +env: + WEQ_REQUIRE_AUTHORIZATION_HELPER: '1' + jobs: # ========================= QQ 通知门禁 ========================= # 只有「最后一次 commit」以 Release: / chore(release): 开头时才发 QQ 群通知; @@ -55,6 +58,12 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 + - name: Setup Go for Remote helper + uses: actions/setup-go@v5 + with: + go-version-file: tools/remote-network/go.mod + cache-dependency-path: tools/remote-network/go.sum + - name: Setup Node uses: actions/setup-node@v4 with: @@ -69,7 +78,7 @@ jobs: # 不能混版本)。--require-fresh 卡住 30 天有效期,--verify 当场 require 一次 # 跑 getInitStatus(),二进制有问题在这里就炸,而不是出厂后才发现。 - name: Fetch nt_helper native artifacts - run: node scripts/fetch-native.mjs --require-fresh --verify + run: node scripts/fetch-native.mjs --require-fresh --require-authorization --verify # 把 tag 里的版本号(v1.2.3 -> 1.2.3)写进 package.json, # 让 electron-builder 命名产物、app.getVersion() 也能读到。 @@ -81,16 +90,6 @@ jobs: - name: Build clone-bot runtime run: pnpm run build:bot - # Rust 守护进程二进制 → resources/daemon/-/, - # electron-builder 随 extraResources 打进安装包。 - # runner 镜像预装 Rust 不可靠(windows 上就没有 cargo),显式装一次; - # 缺工具链时 build:daemon 会直接失败退出。 - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Build daemon binary - run: pnpm run build:daemon - - name: Build renderer + main run: pnpm --filter @weq/desktop exec electron-vite build @@ -160,7 +159,7 @@ jobs: # 原生 x64 runner 构建 AppImage / tar.gz。native/linux/x64/*.node 已预编译, # sherpa-onnx / resvg 等原生依赖由 pnpm install 自动装 x64 版本。 # ⚠️ 必须显式传 --x64:electron-builder 的 target.arch 会覆盖 CLI 的架构参数, - # 而 native binding / daemon 都是构建机架构的,两者一旦不一致就会打出坏包 + # 而 native binding 都是构建机架构的,两者一旦不一致就会打出坏包 # (见 electron-builder.yml 里 linux 一节的说明)。 linux-x64: needs: [windows] @@ -171,6 +170,12 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 + - name: Setup Go for Remote helper + uses: actions/setup-go@v5 + with: + go-version-file: tools/remote-network/go.mod + cache-dependency-path: tools/remote-network/go.sum + - name: Setup Node uses: actions/setup-node@v4 with: @@ -183,7 +188,7 @@ jobs: # 同 windows:先取 nt_helper 原生产物,再开始打包。 - name: Fetch nt_helper native artifacts - run: node scripts/fetch-native.mjs --require-fresh --verify + run: node scripts/fetch-native.mjs --require-fresh --require-authorization --verify # 打包前先把「架构对不对」验掉:resvg / sherpa 的 .node binding 和 cargo # 编出来的守护进程都取自构建机。与 macOS 两个 job 同款把关 —— v1.0.0 的 @@ -204,21 +209,6 @@ jobs: - name: Build clone-bot runtime run: pnpm run build:bot - # Rust 守护进程二进制 → resources/daemon/-/, - # electron-builder 随 extraResources 打进安装包。 - # runner 镜像预装 Rust 不可靠(windows 上就没有 cargo),显式装一次; - # 缺工具链时 build:daemon 会直接失败退出。 - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Build daemon binary - run: pnpm run build:daemon - - # cargo 默认按构建机出二进制,再确认一次它落到了目标架构的目录里。 - - name: Verify daemon binary architecture - run: | - file resources/daemon/linux-x64/weq-daemon | grep -q 'x86-64' - - name: Build renderer + main run: pnpm --filter @weq/desktop exec electron-vite build @@ -245,7 +235,7 @@ jobs: echo "checking $APP_DIR" test -d "$APP_DIR/resources/app.asar.unpacked/node_modules/@resvg/resvg-js-linux-x64-gnu" test -d "$APP_DIR/resources/app.asar.unpacked/node_modules/sherpa-onnx-linux-x64" - file "$APP_DIR/resources/resources/daemon/linux-x64/weq-daemon" | grep -q 'x86-64' + test ! -e "$APP_DIR/resources/resources/daemon" - name: Restore dev manifest if: always() @@ -275,6 +265,12 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 + - name: Setup Go for Remote helper + uses: actions/setup-go@v5 + with: + go-version-file: tools/remote-network/go.mod + cache-dependency-path: tools/remote-network/go.sum + - name: Setup Node uses: actions/setup-node@v4 with: @@ -287,7 +283,7 @@ jobs: # 同 windows:先取 nt_helper 原生产物,再开始打包。 - name: Fetch nt_helper native artifacts - run: node scripts/fetch-native.mjs --require-fresh --verify + run: node scripts/fetch-native.mjs --require-fresh --require-authorization --verify # 同 linux-x64:构建机架构必须就是目标架构,否则 resvg / sherpa binding # 与守护进程都会是另一个架构的。 @@ -305,20 +301,6 @@ jobs: - name: Build clone-bot runtime run: pnpm run build:bot - # Rust 守护进程二进制 → resources/daemon/-/, - # electron-builder 随 extraResources 打进安装包。 - # runner 镜像预装 Rust 不可靠(windows 上就没有 cargo),显式装一次; - # 缺工具链时 build:daemon 会直接失败退出。 - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Build daemon binary - run: pnpm run build:daemon - - - name: Verify daemon binary architecture - run: | - file resources/daemon/linux-arm64/weq-daemon | grep -q 'aarch64' - - name: Build renderer + main run: pnpm --filter @weq/desktop exec electron-vite build @@ -343,7 +325,7 @@ jobs: echo "checking $APP_DIR" test -d "$APP_DIR/resources/app.asar.unpacked/node_modules/@resvg/resvg-js-linux-arm64-gnu" test -d "$APP_DIR/resources/app.asar.unpacked/node_modules/sherpa-onnx-linux-arm64" - file "$APP_DIR/resources/resources/daemon/linux-arm64/weq-daemon" | grep -q 'aarch64' + test ! -e "$APP_DIR/resources/resources/daemon" - name: Restore dev manifest if: always() @@ -376,6 +358,12 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 + - name: Setup Go for Remote helper + uses: actions/setup-go@v5 + with: + go-version-file: tools/remote-network/go.mod + cache-dependency-path: tools/remote-network/go.sum + - name: Setup Node uses: actions/setup-node@v4 with: @@ -388,7 +376,7 @@ jobs: # 同 windows:先取 nt_helper 原生产物,再开始打包。 - name: Fetch nt_helper native artifacts - run: node scripts/fetch-native.mjs --require-fresh --verify + run: node scripts/fetch-native.mjs --require-fresh --require-authorization --verify # 打包前先确认 Node 架构符合目标,避免再打出「arm64 壳 + x64 binding」 # 的坏包(v0.4.4 已踩过一次)。 @@ -404,16 +392,6 @@ jobs: - name: Build clone-bot runtime run: pnpm run build:bot - # Rust 守护进程二进制 → resources/daemon/-/, - # electron-builder 随 extraResources 打进安装包。 - # runner 镜像预装 Rust 不可靠(windows 上就没有 cargo),显式装一次; - # 缺工具链时 build:daemon 会直接失败退出。 - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Build daemon binary - run: pnpm run build:daemon - - name: Build renderer + main run: pnpm --filter @weq/desktop exec electron-vite build @@ -472,6 +450,12 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 + - name: Setup Go for Remote helper + uses: actions/setup-go@v5 + with: + go-version-file: tools/remote-network/go.mod + cache-dependency-path: tools/remote-network/go.sum + - name: Setup Node uses: actions/setup-node@v4 with: @@ -489,7 +473,7 @@ jobs: # 同 windows:先取 nt_helper 原生产物,再开始打包。 - name: Fetch nt_helper native artifacts - run: node scripts/fetch-native.mjs --require-fresh --verify + run: node scripts/fetch-native.mjs --require-fresh --require-authorization --verify # 同上:装完依赖后校验 Node 架构,并确认 arm64 binding 真的进包。 - name: Verify Node architecture @@ -505,16 +489,6 @@ jobs: - name: Build clone-bot runtime run: pnpm run build:bot - # Rust 守护进程二进制 → resources/daemon/-/, - # electron-builder 随 extraResources 打进安装包。 - # runner 镜像预装 Rust 不可靠(windows 上就没有 cargo),显式装一次; - # 缺工具链时 build:daemon 会直接失败退出。 - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Build daemon binary - run: pnpm run build:daemon - - name: Build renderer + main run: pnpm --filter @weq/desktop exec electron-vite build @@ -646,7 +620,7 @@ jobs: # 浏览器版同样带 native/ 与 resources/(含装扮资源),所以也要取一份; # 每个 matrix runner 取的正是它自己平台那一份。 - name: Fetch nt_helper native artifacts - run: node scripts/fetch-native.mjs --require-fresh --verify + run: node scripts/fetch-native.mjs --require-fresh --no-authorization --verify # 与桌面版各平台 job 同款校验:避免 Rosetta / 架构错配导致打包出 # 「arm64 壳 + x64 binding」的坏包。 @@ -662,14 +636,6 @@ jobs: run: node scripts/set-version.mjs ${{ github.ref_name }} - # 浏览器版用户自备 Node,守护进程补上二进制:本 runner 只构建并打进 - # 当前平台那一份(build-server / pack 会按 process.platform/arch 认定)。 - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Build daemon binary - run: pnpm run build:daemon - # 含 check 守卫:确认没有 Electron 代码泄漏进 web 构建。 - name: Build + guards run: pnpm --filter @weq/web build @@ -811,7 +777,7 @@ jobs: - Linux arm64: \`weq-web-${TAG#v}-linux-arm64.tar.gz\` - macOS Intel (x64): \`weq-web-${TAG#v}-darwin-x64.tar.gz\` - macOS Apple Silicon (arm64): \`weq-web-${TAG#v}-darwin-arm64.tar.gz\` - 每个包自带对应平台的 native 原生模块与 Rust 守护进程二进制, + 每个包自带对应平台的 native 原生模块,助手资源服务随应用运行, 详见[使用说明](https://github.com/${REPO}/blob/main/apps/web/README.md) ### macOS 首次打开 diff --git a/.gitignore b/.gitignore index 24bc242ea..db643b003 100644 --- a/.gitignore +++ b/.gitignore @@ -42,8 +42,7 @@ logs/ # electron-builder release/ -# Rust daemon build output (staged into resources/daemon/ by build:daemon, -# packaged at release time — never committed) +# 旧 daemon 的本机可再生产物仍不入库,也不再随新版本打包。 packages/daemon/target/ resources/daemon/ @@ -70,6 +69,8 @@ resources/ninebird-runtime/ # `pnpm native:fetch` 取最新(release 流水线在打包前也会取一次)。 # 注意:native///ninebird/* 仍然入库,是另一条手工同步的链。 /native/*/*/nt_helper.node +/native/*/*/weq-authorization-helper +/native/*/*/weq-authorization-helper.exe # `pnpm native:fetch` 记「当前装的是哪个 tag」用,天然是机器相关的 /native/.installed.json /resources/dress/*.dat @@ -105,3 +106,4 @@ resources/ninebird-runtime/ models/ tmp_reverse/ .zcode/ +/tools/remote-network/build/ diff --git a/AGENTS.md b/AGENTS.md index 6876fc9b8..86c604644 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,9 +11,8 @@ WeQ 是 QQ NT 本地聊天记录的解密 / 导出 / 查看 / 年度报告工具 | 路径 | 是什么 | | --- | --- | | `apps/desktop` | Electron 桌面端(主进程 / 渲染进程 / 预加载) | -| `apps/web` | 机器人 WebUI | +| `apps/web` | 浏览器版 WeQ,复用桌面业务界面与后端 | | `packages/*` | protocol、db、codec、service、bot、native、testkit 等库 | -| `packages/daemon` | Rust 守护进程 | | `native` | nt_helper 原生产物与 pinned 清单(二进制不入库) | | `docs/` | 文档中心:`guide` 用户向、`develop` 开发向、`principles` 原理、`database` 表结构 | @@ -41,21 +40,21 @@ git checkout -b feat/your-feature ## 2. 本地检查:按需跑,别每次都跑全量 -**为了省时间,push 之前不用跑全量 `pnpm ci:local`。** 它把 `.github/workflows/ci.yml` 的门禁在本地按同样顺序复刻一遍(lint / format / typecheck / 单测 / Rust 三项),整轮很慢;日常改动跑一下**和改动相关的那一两项**就够,最终以 CI 结果为准。 +**为了省时间,push 之前不用跑全量 `pnpm ci:local`。** 它把 `.github/workflows/ci.yml` 的门禁在本地按同样顺序复刻一遍(lint / format / typecheck / 单测),整轮很慢;日常改动跑一下**和改动相关的那一两项**就够,最终以 CI 结果为准。 - **CI 跑在 Linux 上**,Windows 本地跑红**不一定**是真问题(路径、换行、大小写、原生模块等平台差异都可能误报)。Windows 本地红的项,先判断是不是平台差异,别急着改代码。 - 改了 TS / 前端:`pnpm lint`、`pnpm typecheck` 通常就够;只动了某个包可以 `pnpm --filter @weq/ test`。 - 改了格式就 `pnpm format`(或 `pnpm lint:fix`)刷一遍,别手写对齐。 -- 动了 `packages/daemon`:在 `packages/daemon` 下跑 `cargo fmt --check`、`cargo clippy --all-targets --all-features -- -D warnings`、`cargo test`。 - 发版前 / 大改动 / 心里没底时,再跑全量: ```bash pnpm ci:local # 全量门禁;不 fail-fast,跑完给一张汇总表 pnpm ci:local --fix # 先 pnpm format + lint:fix 刷平格式再跑门禁 pnpm ci:local --full # 额外跑 web 打包冒烟(≈ CI 的 build-web) -pnpm ci:local --no-daemon # 本机没 Rust 时跳过 cargo 三项 ``` +- 独立 daemon 已移除,不再需要 Rust 工具链。 +- 需要单独跑某一步时,等价命令:`pnpm lint`、`pnpm format:check`、`pnpm typecheck`、`pnpm -r test`; - 真跑红的检查就如实报告失败项和报错。**不要**为了让门禁变绿去放宽规则、加 `// biome-ignore`、塞 `any` / `@ts-ignore`、跳过或删除测试。 - 打包链路(electron-builder)不在 PR 门禁里。改到它时用 `pnpm --filter @weq/desktop build:unpack` 本地验一遍。 @@ -95,7 +94,7 @@ pnpm ci:local --no-daemon # 本机没 Rust 时跳过 cargo 三项 - **颜色一律走 design token,不写死。** token 定义在 `apps/desktop/src/renderer/src/styles/index.css`:一套 shadcn 风格的基础色(`--background`、`--foreground`、`--card`、`--muted`、`--border`、`--primary`、`--ring`、`--radius`),一套 WeQ 语义色(`--weq-bg-surface`、`--weq-fg-primary` / `--weq-fg-secondary` / `--weq-fg-muted`、`--weq-border-subtle` / `--weq-border-strong`、`--weq-shadow-soft` / `--weq-shadow-card`)。 Tailwind 里直接用 `bg-background`、`text-foreground`、`text-muted-foreground`、`border-border`、`bg-primary` 这类工具类。 -- **主题色用 `--weq-accent-effective`**(用户在「设置 → 个性显示 → 主题色」里挑的自定义色优先,没挑就回落到预设 `--weq-accent`)。强调色、描边、高亮、图表取色都用它;要淡一点、要半透明就用 `color-mix(in srgb, var(--weq-accent-effective) 15%, transparent)`,**不要**写死 `#0099ff` 或自己另挑一支蓝。 +- **主题色用 `--weq-accent-effective`**(用户在「设置 → 常规 → 主题与背景 → 主题色」里挑的自定义色优先,没挑就回落到预设 `--weq-accent`)。强调色、描边、高亮、图表取色都用它;要淡一点、要半透明就用 `color-mix(in srgb, var(--weq-accent-effective) 15%, transparent)`,**不要**写死 `#0099ff` 或自己另挑一支蓝。 - **深色模式的差异要显式写覆盖**,它是独立一套值而不是浅色取反。主题钩在 `html[data-theme="dark"]` 上(`state/theme.ts` 同时 toggle `.dark` 类,两个都认),所以写 `html[data-theme="dark"] .your-class { ... }`。 - **别用 Tailwind 的 `dark:` 工具类。** 本项目的暗色钩子是 `data-theme`,`dark:` variant 只被桥接给 streamdown 用过(见 `styles/index.css` 里 `@custom-variant dark` 的注释),别处一律不生效。 - 顺带检查那些「浅色下没事、深色下就废」的写法:`rgba(255, 255, 255, …)` 遮罩、白底渐变、写死的浅灰描边、`--weq-bar-surface` 这类半透明玻璃质感栏。 @@ -149,7 +148,7 @@ pnpm ci:local --no-daemon # 本机没 Rust 时跳过 cargo 三项 ## 10. 收工前自检 - [ ] 落点正确(提 PR 走特性分支、直接 push 落 `dev`),改动聚焦单一主题 -- [ ] 跑过和改动相关的检查项(`pnpm lint` / `pnpm typecheck` / 相关包单测 / cargo 三项);不要求 push 前跑全量 `pnpm ci:local`,最终以 CI 为准 +- [ ] 跑过和改动相关的检查项(`pnpm lint` / `pnpm typecheck` / 相关包单测);不要求 push 前跑全量 `pnpm ci:local`,最终以 CI 为准 - [ ] 新功能 / bug 修复已写进 `CHANGELOG.md` 的 `## Unreleased - 进行中`(没有这一节就先补上) - [ ] 该加的单测加了,且没碰真 QQ 数据 / 网络 - [ ] 动过 UI 的话:浅色 / 深色 / 换主题色都看过一眼 diff --git a/CHANGELOG.md b/CHANGELOG.md index ead0b6ab1..5add1eb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -530,3 +530,69 @@ - 推荐回复不再闪空,「换一批」可单独生成,补上时间、身份与 @ 指向 - 发送文本沿用 QQ 当前聊天字体 - 文件与保存弹窗分别记住上次位置 + +## 2.3.0 - 2026-10-26 +- **设置、搜索与联系人界面升级** +- **设置重构**:七个分类、37 个页面,旧入口与搜索继续可达 + - 数据库、资源管理、防撤回与日志直接在页内操作,QQNT 抓包使用独立窗口 + - 切页保留草稿与滚动,统一深浅主题、分段选择与窄屏布局 + - API 配置区分 LLM / TTS,各 AI 功能可继承默认模型;旧备份缺失项保留本机偏好 +- **全局搜索**:标题栏常驻入口,支持消息、联系人、文件、空间及功能设置等十类搜索 + - 范围自动保存,结果去重、按匹配度排序,支持分页与精确定位 +- **联系人详情工作区**:资料、关系、个性主页与互动画像集中查看 + - 群聊整合公告、相册、文件、精华与成员搜索;好友新增页内私聊预览 + - 列表统一多选、反选与读状态操作,支持联系人未读数和所选群成员名单导出 +- 账号入口重排,最近账号一键打开,添加账号、备份导入与密钥恢复各自独立 +- 新增本机长期授权管理,兼容的权限助手可减少重复授权并自动启用完整功能;QQ 离线时可直接启动 +- 左下角菜单整合任务、定时计划与助手会话,数字角标区分在途任务和未读结果 +- 首页标签、问候语与一言支持独立开关,新增一言屏蔽词 +- **聊天体验优化**: + - 连续发送支持排队与点击重试,会话预览、时间和排序即时更新 + - 输入区合并表情、语音与文件入口,消息管理补齐分析和导出,移除独立窗口抖动 + - 气泡与字体可独立开关,装扮默认按消息记录还原,可选择使用 QQ 账号字体发送 + - 新增快速回到底部与最新消息预览,修复引用定位后跳回底部、列表重排时误开会话 + - 修复 @ 名片点击与头像缺失、发送中图片预览失败、撤回自己消息时防撤回失效 +- 意图感知与互动画像独立配置和运行,修复偏好备份恢复;优化助手工具选用与流式回答 +- **Web / Remote 增强**:支持当前设备发送文件,内置 Web 可导出报告 PDF、长图与空间分享图 + - 并列展示局域网与 Tailscale 地址,支持统一配对、多入口连接与重连,优化手机导航 +- 统一「每次询问 / 自动保存」入口,移除「稍后保存」;备份与导出目录扫描边发现边显示 +- 隐私模式补齐独立预览与悬浮提示遮罩;菜单截图可临时启用隐私模式,悬停一秒后截图不含菜单与按钮 +- 移除独立常驻守护进程,完全退出不留后台服务;自启默认驻留托盘,可设置显示窗口 +- 修复 macOS 更新签名不匹配、Cmd+Q 无法彻底退出,以及开发版重启后窗口无法打开 +- 缩小安装包与应用体积,保留离线功能和全部界面语言 +- 优化消息同步、未读红点与会话切换,减少长聊天和图片表情的内存占用,异常页面可手动恢复 +- 修复完整模式凭据补齐、授权进度与受限提示残留,以及消息首屏持续加载 +- 修复大消息库密钥扫描闪退、离线语音转写批量失败、通话漏帧与挂断状态异常 +- 修复跨端邮件未读数、群助手时间显示,改善深浅主题可读性与小窗口布局 +- 修复未处理的好友通知被误标为已同意、收发方向错误及审批按钮缺失 + +## Unreleased - 进行中 +- 移除联系人菜单中尚未实现的添加好友/群聊和创建 QQ 群入口,保留号码搜索与好友分组管理 +- 只读消息接口补齐原始发送时间和稳定发送者,避免外部整理工具依赖已禁用的 SQL 权限 +- 任务列表支持一键清除已完成任务,操作前确认项目数并提醒尚未保存的导出结果 +- **助手新增权限模式**:默认沙箱隔离脚本与高风险操作,任意权限需先确认风险,可在当前系统用户权限内操作和删除外部文件 +- 收紧外部 MCP 为只读工具,阻止跨来源跳转泄露凭据,并更新桌面与 Remote 运行时安全补丁 +- 修复克隆记录路径越界,增强配置与克隆保存的中断保护,损坏原件不再被静默覆盖 +- 修复恢复记录保存失败后仍删除消息的问题,保留写库失败时的恢复信息 +- 修复确认框被其他提示替换后操作悬挂,消息编辑器补齐名称与键盘焦点支持 +- 修复桌面打包缺少 Remote 网络辅助程序,构建时核对目标平台与包内文件 +- 修复视频通话画面播放二十来秒后永久卡住的问题:帧序号跨 256 帧后不再被误判为「非基础层」丢弃,本端上行也改为 16 位帧序号,两端画面持续更新。 +- 修复视频通话画面卡顿:摄像头帧改走二进制 IPC 通道,不再把每帧展开成 1.2 MB 文本经 JSON 传输,采集与编码不再互相拖慢。 +- 修复视频通话界面读不到对方分辨率的问题,关键帧参数集解析偏移已校正。 +- 视频通话补齐 GCS 视频流状态握手,并停止伪造仅由服务端下发的视频参数推送。 +- 继续对齐视频通话的上行帧格式:对端未发视频时基准字段填 0、收到后改用对端时间基准,分片标记字段按实测分布填写。 +- 修复视频通话媒体退化成全程服务器中继、两端画面都极卡的问题:服务器选路不再选中继端口,对端直连地址识别不再被 IPv4 映射地址混淆。 +- 修复视频通话拿不到对端直连地址、媒体只能全程走服务器中继的问题:GCS 认证改为发送与 QQ 一致的音视频能力集。 +- 继续对齐视频通话信令:开房与邀请请求的字段与 QQ 本体逐字节一致。 +- 修复视频通话对端久等才看到画面、媒体全程走服务器中继的问题:补齐 P2P 端口交换与直连握手,媒体确认直连后不再全程走服务器中继;同时修正中继注册报文的序号与对端字段,服务器重新给予中继应答。 +- 修复私聊位置共享发起报 `param error`:会话键改为按 uin 升序排列两个参与者,两个方向的发起现在都能成功。 +- 弹射表情和互动表情直接纳入表情面板,录音与文字转语音合并到输入框内的语音面板 +- 优化闪传文件浏览界面,移除独立下载任务列表;一次下载作为一项常规任务,集中查看文件进度和取消下载 +- 减少在线凭据的重复获取,修复 QQ 重启后复用旧票据的问题;邮箱明确要求安全验证时暂停后台重试,完成验证后可手动重连 +- 修复联系人未选择好友或群聊时显示占位页的问题,默认展示群交集首页 +- 修复已创建克隆体在会话列表和新建克隆群聊中消失的问题,后台创建完成后自动刷新列表 +- 好友克隆支持使用最新聊天记录更新画像,保留原有配置、聊天历史、记忆和群聊关系,更新失败不覆盖旧画像 +- 补齐主页、存储管理、聊天消息等设置页标题缺失的图标 +- 修复资源浏览页右侧内容区没有撑满窗口、右边留出大片空白的问题,所有资源分类一并生效 +- 修复数据库管理页标题上方留白明显大于同级设置页的问题,标题与内容边距已对齐 +- 修复视频通话拿到服务端下发的对端 P2P 端口后仍打不通直连的问题:权威端口到达时立即补发打洞探测,不再等下一拍而被探测超时清掉。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d965230f3..fc0326b5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,7 +7,7 @@ ## 目录 - [开发环境](#开发环境) -- [提交前检查(必须)](#提交前检查必须) +- [提交前检查](#提交前检查) - [分支规范](#分支规范) - [Commit 规范](#commit-规范) - [提交 Pull Request](#提交-pull-request) @@ -26,16 +26,20 @@ pnpm native:fetch # 取 nt_helper 原生产物(不入库,dev 克隆里没 pnpm dev # 启动桌面端开发 ``` -更多背景见[项目架构](./docs/develop/architecture.md)与[原理文档](./docs/principles/index.md)。 +更多背景见[开发者入口](./docs/develop/index.md)与[原理文档](./docs/principles/index.md)。 --- -## 提交前检查(必须) +## 提交前检查 -提交 PR 前,请跑通本地 CI 门禁,确保没有引入回归: +日常改动先运行与修改直接相关的检查,并在 PR 中说明验证范围与结果;不要求每次提交或 +push 前运行全量本地 CI。只改文档时核对命令、链接与源码行为;修改纯逻辑或修复 bug 时 +补充针对性回归测试。最终以 CI 全量门禁为准。 + +发版前、大改动或需要全量复核时,可运行: ```bash -pnpm ci:local # 一键跑 CI 的 lint / format / typecheck / test / daemon 门禁 +pnpm ci:local # 一键跑 CI 的 lint / format / typecheck / test 门禁 ``` `pnpm ci:local` 把 `.github/workflows/ci.yml` 的门禁在本地按同样顺序复刻一遍, @@ -44,10 +48,9 @@ pnpm ci:local # 一键跑 CI 的 lint / format / typecheck / test / daemon 门 - `pnpm ci:local --fix` —— 先 `pnpm format` + `pnpm lint:fix` 刷平,再跑门禁。 改完代码懒得管格式时用这个,省得再来一轮。 - `pnpm ci:local --full` —— 额外跑 web 打包冒烟(≈ CI 的 `build-web` job)。 -- `pnpm ci:local --no-daemon` —— 跳过 Rust 三项(本机没装 Rust 时用)。 - `pnpm ci:local --fail-fast` / `--list` —— 失败即停 / 只打印将跑的步骤。 -脚本会自己判断本机有没有 `cargo`,没有就自动跳过 Rust 三项并给出提示。 +独立 daemon 已移除,本工程不再需要 Rust 工具链。 需要单独跑某一步时,等价命令是: @@ -55,14 +58,12 @@ pnpm ci:local # 一键跑 CI 的 lint / format / typecheck / test / daemon 门 pnpm lint # Biome 代码规范检查 pnpm format:check # Biome 格式检查 pnpm typecheck # 类型检查(CI 强制,不通过会被拦截) -pnpm -r test # 全量单测(含 @weq/web 与 daemon) +pnpm test # 有界并发的全量单测(含 @weq/web),汇总所有失败 +pnpm test:fail-fast # 全量范围,首个失败后尽早停止(CI 使用) +pnpm test:changed # 日常只跑改动相关的 Vitest 测试;Web 仍跑完整服务端断言 pnpm check # Biome 的 lint + format 汇总检查 pnpm --filter @weq/web build -# daemon 包的三项,cd 到 packages/daemon 再跑 -cargo fmt --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test ``` > 桌面端打包(`electron-vite build` + `electron-builder`)不在 PR 门禁里, @@ -133,7 +134,7 @@ git checkout -b feat/your-feature ## 提交 Pull Request 1. **确认基础分支为 `dev`**(见[分支规范](#分支规范))。 -2. 本地跑通[提交前检查](#提交前检查必须)。 +2. 完成相关[提交前检查](#提交前检查),说明未运行或未通过的项目。 3. **判断你的改动是否属于「全新功能」**: - **全新功能必须先提 [Issue](../../issues) 讨论并达成一致,再提交 PR。** 未经 Issue 讨论直接提交的新功能 PR 会被关闭(close)。 - 这样做是为了在你投入大量精力前对齐方向,避免功能与项目规划冲突而白费工作。 diff --git a/README.md b/README.md index 1bd60ce18..19aa703b4 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ ## 使用方法 -1. 前往 [Releases](../../releases) 下载最新版本 +1. 前往 [Releases](https://github.com/weq-devs/WeQ/releases) 下载最新版本 2. 按照引导操作获取数据库密钥 3. 打开对应账号即可开始使用 @@ -56,7 +56,7 @@ ### web版 -除桌面版外还提供 **WeQ Web** —— 同一套界面与功能,跑在浏览器里。适合无桌面环境的机器 +除桌面版外还提供 **WeQ Web**,复用桌面版的业务界面与后端,原生外壳能力存在差异。适合无桌面环境的机器 (NAS / 服务器 / WSL),或想从别的设备访问。 下载 `weq-web-<版本>-<平台>.tar.gz`(每个平台一个包,**需自备 Node ≥ 22**), @@ -70,6 +70,9 @@ start.bat # Windows 终端会打印地址和访问令牌,浏览器打开即可。默认只监听本机; **对外暴露前请先读 [apps/web/README.md](./apps/web/README.md)**。 +已运行桌面版时,也可从「设置 → WeQ 服务 → Web 与 Remote」开启内置 Web,复用当前账号, +无需再启动独立 Node 服务。手机连接与两种 Web 宿主的能力边界见[Web 服务与手机连接](./docs/guide/remote-web.md)。 + #### 开发者指南 开始运行: @@ -77,25 +80,27 @@ start.bat # Windows ```bash git clone https://github.com/weq-devs/WeQ # 克隆仓库代码 cd WeQ +git switch dev # 开发基于 dev,main 为发布分支 pnpm i # 安装依赖(electron可能需要单独处理) pnpm native:fetch # 取 nt_helper 原生产物(nt_helper.node + 装扮资源,不入库) -pnpm run build:bot # 构建bot代码 -pnpm run build:daemon # 构建守护进程代码 +pnpm run build:bot # 构建机器人运行时(使用机器人功能时需要) pnpm dev # 启动开发服务器 ``` +源码开发需要 Node.js ≥ 22.12.0,并使用根 `package.json` 锁定的 `pnpm@12.8.1`。 + > `native/**/nt_helper.node` 与 `resources/dress/*.dat` 不在仓库里(历史太大), > 由 [nt_helper_release](https://github.com/weq-devs/nt_helper_release) 分发, -> 隔一段时间重新 `pnpm native:fetch` 一次即可 —— 细节见 +> 默认取 `native/pinned.json` 锚定的版本;锚点过期时需验证并升级锚点,反复获取同一 tag 不会延长有效期。细节见 > [原生二进制与装扮资源的分发](./docs/develop/native-artifacts.md)。 打包发布: ```bash -pnpm run build # = build:bot + build:ninebird + 桌面版构建 + electron-builder(安装包) -pnpm --filter @weq/web build # 浏览器版(先跑 pnpm run build:daemon 保证随包二进制齐全) +pnpm run build # = build:bot + 桌面版构建 + electron-builder(安装包) +pnpm --filter @weq/web build # 浏览器版(助手资源服务随 Web 进程运行) ``` > 贡献代码请先阅读 [贡献指南](./CONTRIBUTING.md) 作为参考 diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh index 8030ad777..bafb4b458 100644 --- a/apps/desktop/build/installer.nsh +++ b/apps/desktop/build/installer.nsh @@ -21,6 +21,9 @@ ; 推文卡片的服务记忆也会一起没。这个判断与 electron-builder 的 uninstaller.nsh ; 模板在 un.install 段里的用法完全一致。 ${ifNot} ${isUpdated} + ; 当前版本由系统直接启动 WeQ,不再借助独立后台。 + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "app.weq.desktop" + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run" "app.weq.desktop" ; 1) 停掉正在跑的守护进程:它是常驻进程,不主动停就活到重启(而且是它把 ; 数据目录里的二进制占着,导致下面删不掉)。本来就不在时这条命令会失败, ; 忽略即可。 diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 4d16c7b68..da12d75ab 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -4,8 +4,13 @@ copyright: Copyright © 2026 weQ contributors directories: output: release/${version} buildResources: build +beforePack: ../../scripts/package-remote-network.mjs +afterPack: ../../scripts/trim-desktop-package.mjs +afterSign: ../../scripts/verify-desktop-authorization.mjs files: - "out/**/*" + - "!out/types-*/**" + - "!out/**/*.d.{ts,cts,mts}" # Bare "package.json" was being resolved as a glob in this monorepo and # matched `../../packages//package.json` etc., which electron-builder # rejects ("must be under "). Pin to the desktop app's own manifest. @@ -15,6 +20,10 @@ files: # Workspace packages are removed from the release manifest before packaging. - "node_modules/**/*" - "!node_modules/@weq/**/*" + # 前端依赖仅用于构建;运行依赖也不携带源码映射和 ExcelJS 的浏览器整包。 + - "!node_modules/**/*.map" + - "!node_modules/**/*.d.{ts,cts,mts}" + - "!node_modules/exceljs/dist/**" # onnxruntime-node 是原生模块:留在 asar 里会被 electron-builder 的 smartUnpack # **整包**解出一份到 app.asar.unpacked/(288 MB 的包体,2.0.5 的 Windows 安装包 # 因此直接翻倍)。运行时真正加载的是下面 extraResources 拷到 /node_modules/ @@ -32,7 +41,7 @@ extraResources: to: out/main/chunks - from: ../web/dist/public to: remote-web - - from: ../../tools/remote-network + - from: ../../tools/remote-network/build/${os}/${arch} to: remote-network filter: - weq-remote-network @@ -58,9 +67,7 @@ extraResources: - "**/*" - "!assistant/fonts/**" - "!brand/banner.png" - - - from: node_modules/sherpa-onnx-node - to: node_modules/sherpa-onnx-node + - "!daemon/**" - from: out/main/transcribeWorker.mjs to: out/main/transcribeWorker.mjs @@ -77,10 +84,7 @@ extraResources: # 意图感知本地引擎(Laya ONNX)在 fork 的子进程里 require 这些包。 - from: node_modules/@receptron/laya to: node_modules/@receptron/laya - - from: node_modules/adm-zip - to: node_modules/adm-zip - - from: node_modules/global-agent - to: node_modules/global-agent + # adm-zip / global-agent 仅供 ONNX 的安装脚本使用,不是推理运行依赖。 - from: node_modules/@huggingface/tokenizers to: node_modules/@huggingface/tokenizers @@ -90,15 +94,11 @@ extraResources: to: node_modules/onnxruntime-common asarUnpack: + # 转写 Node 子进程从实体目录加载 JS 包,平台原生绑定由 smartUnpack 放在同级。 + - "**/node_modules/sherpa-onnx-node/**/*" - node_modules/sharp/**/* - node_modules/@img/**/* - # @resvg/resvg-js loads a platform .node binding (used by the WeQ 助手 cover - # renderer in the main process). Native addons can't be loaded from inside the - # asar, so unpack the resvg packages (base + per-platform binding). The - # darwin bindings (x64 + arm64) are declared as direct deps of the desktop - # app and both get unpacked here; js-binding.js picks whichever matches - # process.arch at runtime, so the packaging no longer depends on the build - # runner's own architecture. + # resvg 原生绑定在 asar 外加载;afterPack 移除非目标 Darwin 架构。 - node_modules/@resvg/**/* # # The voice-transcription worker is forked as a child process (can't run from # # inside the asar) and loads the native sherpa-onnx binding from node_modules. @@ -139,8 +139,7 @@ nsis: allowToChangeInstallationDirectory: true artifactName: ${productName}-${version}-setup.${ext} # 卸载钩子在 buildResources/installer.nsh(electron-builder 按约定自动 include, - # 不要同时用 nsis.include 指一遍 —— 那样宏会重复定义)。那里负责停掉 weq-daemon、 - # 删掉它的开机自启任务与数据目录:electron 的卸载器不认识 %LOCALAPPDATA%\weq-daemon。 + # 不要同时用 nsis.include 指一遍 —— 那样宏会重复定义)。它清理当前登录项及旧后台残留。 # Linux 目标:AppImage(单文件免安装,最通用)+ tar.gz(解压即用)。 # 架构由命令行决定:CI 里每个 runner 只打自己那一种(linux-x64 → --x64, @@ -150,8 +149,7 @@ nsis: # ⚠️ 不要给 target 加 `arch: [x64, arm64]`:electron-builder 的 target.arch 会 # 覆盖 CLI 的 --x64 / --arm64(app-builder-lib computeArchToTargetNamesMap), # 一次构建就会把两种架构都打出来;而 pnpm 装的 native binding -# (@resvg/resvg-js-linux-*-gnu、sherpa-onnx-linux-*)和 cargo 编出来的 -# resources/daemon/- 全是**构建机架构**的,于是非构建机架构的 +# (@resvg/resvg-js-linux-*-gnu、sherpa-onnx-linux-*)全是**构建机架构**的,于是非构建机架构的 # 包会带着错误架构的原生模块出厂:v1.0.0 的 weQ-1.0.0-linux-x86_64.AppImage # 就是这么坏的 —— 在 arm64 runner 上打出来,包里是 aarch64 binding,启动即 # Cannot find module '@resvg/resvg-js-linux-x64-gnu'。 @@ -203,11 +201,14 @@ linux: # 扫码走纯协议,不再需要任何注入或沙箱容器文件。 # 未配置证书时补完整的 ad-hoc 签名并验证资源封装;它不是 Developer ID 或公证, # 首次仍需按「隐私与安全性 → 仍要打开」放行。配置证书时保留真实签名身份。 +# ad-hoc 构建的跨版本更新使用官方 Release 哈希验证和退出后安装助手,不交给 Squirrel。 mac: icon: ../../resources/brand/logo.png sign: ../../scripts/sign-macos.mjs # 打包字体(见顶部 extraResources 的说明):macOS 的候选全是 .ttc,satori 读不了。 extraResources: + - from: out/main/macUpdateWorker.mjs + to: out/main/macUpdateWorker.mjs - from: ../../LICENSE to: LICENSE - from: ../../native/darwin/${arch} diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index e274449fe..b0f45c373 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -16,6 +16,7 @@ const EXCLUDE_FROM_EXTERNAL = [ '@weq/codec', '@weq/db', '@weq/native', + '@weq/native/authorization.mjs', '@weq/platform', '@weq/protocol', '@weq/service', @@ -86,6 +87,7 @@ export default defineConfig({ keyScanWorker: resolve(__dirname, 'src/main/key_scan_worker.ts'), storageCleanupWorker: resolve(__dirname, 'src/main/storage_cleanup_worker.ts'), backupProbeWorker: resolve(__dirname, 'src/main/backup_probe_worker.ts'), + macUpdateWorker: resolve(__dirname, 'src/main/update/mac_worker.ts'), // 意图感知本地引擎:Laya ONNX 推理跑在 fork 的子进程(同 transcribeWorker // 的打包/寻址约定,主进程经 intent_sense/bridge.ts 持有)。 intentSenseWorker: resolve(__dirname, 'src/main/intent_sense/worker.ts'), @@ -99,6 +101,7 @@ export default defineConfig({ chunk.name === 'keyScanWorker' || chunk.name === 'storageCleanupWorker' || chunk.name === 'backupProbeWorker' || + chunk.name === 'macUpdateWorker' || chunk.name === 'intentSenseWorker' ? `${chunk.name}.mjs` : '[name].js', diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 57b0f163c..557554abc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@weq/desktop", - "version": "2.2.2", + "version": "2.3.0", "private": true, "type": "module", "main": "./out/main/index.js", @@ -22,14 +22,13 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@electron/remote": "^2.1.3", - "@modelcontextprotocol/sdk": "^1.29.0", + "@huggingface/tokenizers": "0.2.0", + "@modelcontextprotocol/sdk": "^1.32.2", "@receptron/laya": "^0.1.2", "@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", - "@tanstack/react-query": "^4.44.0", "@trpc/client": "^10.45.4", - "@trpc/react-query": "^10.45.4", "@trpc/server": "^10.45.4", "@weq/account": "workspace:*", "@weq/agentlab": "workspace:*", @@ -41,35 +40,30 @@ "@weq/service": "workspace:*", "@xmldom/xmldom": "^0.9.12", "archiver": "5.3.2", - "d3": "^7.9.0", - "dompurify": "^3.4.17", "electron-trpc": "^0.7.1", "electron-updater": "^6.3.9", "exceljs": "^4.4.0", - "lottie-web": "^5.13.0", "onnxruntime-common": "1.30.0", "onnxruntime-node": "1.30.0", "otplib": "^13.5.0", "qrcode": "^1.5.4", + "quickjs-emscripten": "^0.32.0", "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-icons": "^5.6.0", - "remark-gfm": "^4.0.1", "satori": "^0.12.1", "semver": "^7.6.3", "sharp": "^0.35.5", "sherpa-onnx-node": "^1.13.3", - "shiki": "^4.3.1", "silk-wasm": "^3.7.1", - "streamdown": "^2.5.0", "superjson": "^2.2.2", "ws": "^8.21.0", - "zod": "^3.23.8", - "zustand": "^5.0.3" + "zod": "^3.23.8" }, "devDependencies": { "@electron/osx-sign": "1.3.1", "@tailwindcss/vite": "^4.0.0", + "@tanstack/react-query": "^4.44.0", + "@tanstack/react-virtual": "3.14.22", + "@trpc/react-query": "^10.45.4", "@types/archiver": "5.3.4", "@types/d3": "^7.4.3", "@types/node": "^22.10.6", @@ -81,15 +75,24 @@ "@vitejs/plugin-react": "^6.1.2", "autoprefixer": "^10.4.20", "clsx": "^2.1.1", - "electron": "44.9.0", + "d3": "^7.9.0", + "dompurify": "^3.4.17", + "electron": "44.9.1", "electron-builder": "^25.1.8", "electron-vite": "^6.0.0", "happy-dom": "^21.0.1", "jsdom": "26.1.0", + "lottie-web": "^5.13.0", "lucide-react": "^0.469.0", + "react-dom": "^19.0.0", + "react-icons": "^5.6.0", + "remark-gfm": "^4.0.1", + "shiki": "^4.3.1", + "streamdown": "^2.5.0", "tailwind-merge": "^2.6.0", "tailwindcss": "^4.0.0", "vite": "^8.3.4", - "vitest": "^2.1.9" + "vitest": "^2.1.9", + "zustand": "^5.0.3" } } diff --git a/apps/desktop/src/main/annual_report_pdf.ts b/apps/desktop/src/main/annual_report_pdf.ts index 116bb73c5..c8f0a6809 100644 --- a/apps/desktop/src/main/annual_report_pdf.ts +++ b/apps/desktop/src/main/annual_report_pdf.ts @@ -10,6 +10,7 @@ */ import { BrowserWindow } from 'electron'; +import { randomUUID } from 'node:crypto'; import { loadIsolatedHtml } from './html_load'; /** 渲染 HTML → PDF 字节。 */ @@ -19,6 +20,7 @@ export async function renderPdfFromHtml(html: string): Promise { height: 1300, show: false, webPreferences: { + partition: `weq-report-pdf-${randomUUID()}`, sandbox: true, contextIsolation: true, nodeIntegration: false, @@ -27,7 +29,7 @@ export async function renderPdfFromHtml(html: string): Promise { }); win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); try { - const loaded = loadIsolatedHtml(win, html); + const loaded = loadIsolatedHtml(win, html, { offline: true }); await Promise.race([loaded, new Promise((resolve) => setTimeout(resolve, 8000))]); if (win.isDestroyed()) throw new Error('窗口已销毁'); // 等字体/布局落定,避免首帧缺字。 diff --git a/apps/desktop/src/main/app_quit_confirmation.ts b/apps/desktop/src/main/app_quit_confirmation.ts new file mode 100644 index 000000000..78deb07eb --- /dev/null +++ b/apps/desktop/src/main/app_quit_confirmation.ts @@ -0,0 +1,67 @@ +export type QuitChoice = 'quit' | 'window' | 'cancel'; + +interface QuitEvent { + readonly defaultPrevented?: boolean; + preventDefault(): void; +} + +let skipNext = false; + +/** 用户已经确认的重启或退出后清除,不再被助手的普通退出提示打断。 */ +export function skipNextQuitConfirmation(): void { + skipNext = true; +} + +/** 必须先于保存、释放服务与销毁托盘运行;取消退出不应改变运行状态。 */ +export function registerAppQuitConfirmation( + app: { + prependListener(event: 'before-quit', listener: (event: QuitEvent) => void): unknown; + quit(): void; + }, + updater: { on(event: 'before-quit-for-update', listener: () => void): unknown }, + options: { + needsConfirmation(): boolean; + bypass(): boolean; + showDialog(): Promise; + hideWindow(): void; + failed(error: unknown): void; + }, +): void { + let approved = false; + let pending = false; + let generation = 0; + updater.on('before-quit-for-update', () => { + approved = true; + generation += 1; + }); + app.prependListener('before-quit', (event) => { + if (event.defaultPrevented) return; + const skipped = skipNext; + skipNext = false; + if (approved || skipped || options.bypass() || !options.needsConfirmation()) { + approved = true; + generation += 1; + return; + } + event.preventDefault(); + if (pending) return; + pending = true; + const expected = generation; + void (async () => { + try { + const choice = await options.showDialog(); + if (expected !== generation) return; + if (choice === 'quit') { + approved = true; + app.quit(); + } else if (choice === 'window') { + options.hideWindow(); + } + } catch (error) { + options.failed(error); + } finally { + pending = false; + } + })(); + }); +} diff --git a/apps/desktop/src/main/app_quit_lifecycle.ts b/apps/desktop/src/main/app_quit_lifecycle.ts index a475a158a..313be3a0f 100644 --- a/apps/desktop/src/main/app_quit_lifecycle.ts +++ b/apps/desktop/src/main/app_quit_lifecycle.ts @@ -2,19 +2,80 @@ interface QuitEventSource { on(event: E, listener: () => void): unknown; } -/** 普通退出与更新退出共享准备动作,避免重复保存和释放资源。 */ +/** 全部窗口同意关闭后才停止后台,避免未保存内容取消退出时服务已被关闭。 */ +export function registerAppQuitCleanup( + app: { + on( + event: 'will-quit', + listener: (event: { readonly defaultPrevented?: boolean; preventDefault(): void }) => void, + ): unknown; + quit(): void; + }, + cleanup: () => Promise, + failed: (error: unknown) => void, + updater?: QuitEventSource<'before-quit-for-update'>, +): void { + let cleaning = false; + let cleaned = false; + let updating = false; + // 原生更新先关闭窗口再交给安装器,不把这次交接改成普通 app.quit。 + updater?.on('before-quit-for-update', () => { + updating = true; + }); + app.on('will-quit', (event) => { + if (event.defaultPrevented || cleaned || updating) return; + event.preventDefault(); + if (cleaning) return; + cleaning = true; + void (async () => { + try { + await cleanup(); + } catch (error) { + failed(error); + } finally { + cleaned = true; + // 等 Electron 完成这次 will-quit 取消,避免微任务内重入仍被视为正在退出。 + setImmediate(() => app.quit()); + } + })(); + }); +} + +/** 普通退出与更新退出共享窗口放行标记;取消卸载后允许重置并重新准备。 */ export function registerAppQuitPreparation( app: QuitEventSource<'before-quit'>, updater: QuitEventSource<'before-quit-for-update'>, prepare: () => void, -): void { +): () => void { let prepared = false; const run = () => { if (prepared) return; prepared = true; prepare(); }; - app.on('before-quit', run); + app.on('before-quit', (event?: { readonly defaultPrevented?: boolean }) => { + if (!event?.defaultPrevented) run(); + }); // 更新安装先关闭窗口,再发普通 before-quit;必须提前放行窗口 close。 updater.on('before-quit-for-update', run); + return () => { + prepared = false; + }; +} + +/** Electron 默认静默拦截 beforeunload;由原生确认框明确决定是否放弃更改。 */ +export function registerWindowUnloadConfirmation( + contents: { + on( + event: 'will-prevent-unload', + listener: (event: { preventDefault(): void }) => void, + ): unknown; + }, + options: { confirmDiscard(): boolean; cancelled(): void }, +): void { + contents.on('will-prevent-unload', (event) => { + // 此事件 preventDefault 的含义是忽略渲染层拦截,继续卸载。 + if (options.confirmDiscard()) event.preventDefault(); + else options.cancelled(); + }); } diff --git a/apps/desktop/src/main/attach_elevation.ts b/apps/desktop/src/main/attach_elevation.ts index cc516bc75..906aa286e 100644 --- a/apps/desktop/src/main/attach_elevation.ts +++ b/apps/desktop/src/main/attach_elevation.ts @@ -74,6 +74,7 @@ import { getLogger } from '@weq/service'; import { getSudoPasswordPrompt, requestSudoPassword } from './sudo_prompt'; import { getAttachHintPrompt } from './attach_hint'; import { resolveChildWorkerPath } from './worker_path'; +import { tryLongTermAttach } from './authorization/client'; const logger = getLogger().child({ scope: 'attach-elevation' }); @@ -287,6 +288,17 @@ export function createLinuxAttachHook( box.material = outcome.material; return null; } + if (isPermissionError(outcome.error)) { + try { + const material = await tryLongTermAttach(pid, signal); + if (material) { + box.material = material; + return null; + } + } catch { + signal.throwIfAborted(); + } + } return { permissionDenied: isPermissionError(outcome.error), message: outcome.error.message, @@ -393,6 +405,13 @@ export function createLinuxAttachHook( signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); + try { + const material = await tryLongTermAttach(pid, signal); + if (material) return material; + } catch { + signal?.throwIfAborted(); + // 助手失效不堵住原有临时授权;也不在日志中记录会话物料。 + } let password: string | null = provided ?? null; if (!password && !installInvisibleToRoot()) { try { diff --git a/apps/desktop/src/main/authorization/client.ts b/apps/desktop/src/main/authorization/client.ts new file mode 100644 index 000000000..32a90d865 --- /dev/null +++ b/apps/desktop/src/main/authorization/client.ts @@ -0,0 +1,213 @@ +import { open } from 'node:fs/promises'; +import { connect, type Socket } from 'node:net'; +import type { SessionMaterial, KeyScanResult, KeyScanProgress } from '@weq/native'; +import { AUTHORIZATION_PROTOCOL } from '../../shared/long_term_authorization'; +import type { ElevatedCaptureWorker } from '../capture_elevation'; +import { + MAX_AUTHORIZATION_REPLY, + type AuthorizationRequest, + type AuthorizationHelperInfo, +} from './protocol'; + +let configuration: { + endpoint: () => Promise; + development?: () => Promise; +} | null = null; +export function configureLongTermAuthorization(value: NonNullable): void { + configuration = value; +} +export class AuthorizationError extends Error { + constructor( + message: string, + readonly code = 'unavailable', + ) { + super(message); + } +} +export class AuthorizationConnection { + private sequence = 0; + private pending = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + progress?: (value: KeyScanProgress) => void; + } + >(); + constructor(private readonly socket: Socket) { + let buffer = ''; + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + if (buffer.length > MAX_AUTHORIZATION_REPLY) return this.close(); + for (;;) { + const end = buffer.indexOf('\n'); + if (end < 0) break; + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + try { + const reply = JSON.parse(line) as { + id: number; + ok?: boolean; + result?: unknown; + error?: string; + code?: string; + progress?: KeyScanProgress; + }; + const pending = this.pending.get(reply.id); + if (!pending) continue; + if (reply.progress) { + pending.progress?.(reply.progress); + continue; + } + this.pending.delete(reply.id); + if (reply.ok) pending.resolve(reply.result); + else + pending.reject(new AuthorizationError(reply.error || '长期授权操作失败', reply.code)); + } catch { + this.close(); + } + } + }); + const fail = () => { + for (const pending of this.pending.values()) + pending.reject(new AuthorizationError('长期授权连接已断开')); + this.pending.clear(); + socket.destroy(); + }; + socket.on('error', fail); + socket.on('close', fail); + } + private exchange( + id: number, + payload: unknown, + timeout: number, + progress?: (value: KeyScanProgress) => void, + ): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.close(); + reject(new AuthorizationError('长期授权请求超时')); + }, timeout); + const finish = (action: () => void) => { + clearTimeout(timer); + action(); + }; + if (this.socket.destroyed) { + finish(() => reject(new AuthorizationError('长期授权连接不可用'))); + return; + } + this.pending.set(id, { + resolve: (value) => finish(() => resolve(value as T)), + reject: (error) => finish(() => reject(error)), + progress, + }); + this.socket.write(`${JSON.stringify(payload)}\n`); + }); + } + authenticate(): Promise { + return this.exchange(0, { protocol: AUTHORIZATION_PROTOCOL }, 3000); + } + request( + request: Omit, + timeout = 60_000, + progress?: (value: KeyScanProgress) => void, + ): Promise { + const id = ++this.sequence; + return this.exchange(id, { ...request, id }, timeout, progress); + } + close(): void { + this.socket.destroy(); + } +} + +export async function openLongTermAuthorization(): Promise<{ + connection: AuthorizationConnection; + info: AuthorizationHelperInfo; +}> { + if (!configuration) throw new AuthorizationError('当前宿主未提供长期授权'); + const connection = new AuthorizationConnection(connect(await configuration.endpoint())); + try { + await connection.authenticate(); + const info = await connection.request({ op: 'status', pid: 0 }, 3000); + if ( + info.protocol !== AUTHORIZATION_PROTOCOL || + !['attach', 'key-scan', 'capture'].every((name) => info.capabilities?.includes(name)) + ) + throw new AuthorizationError('权限助手协议不兼容,请在设置中更新助手', 'incompatible'); + if (info.validity === 'expired') + throw new AuthorizationError( + '原生权限助手已过期,请更新原生组件后修复授权', + 'component-expired', + ); + const development = await configuration.development?.(); + if (development !== undefined && info.development !== development) + throw new AuthorizationError('权限助手的开发/正式通道不匹配', 'incompatible'); + // 普通 app/.node 更新不使兼容的系统授权失效。 + return { connection, info }; + } catch (error) { + connection.close(); + throw error; + } +} +export async function connectLongTermAuthorization(): Promise { + try { + return (await openLongTermAuthorization()).connection; + } catch { + return null; + } +} +export async function tryLongTermAttach( + pid: number, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const connection = await connectLongTermAuthorization(); + if (!connection) return null; + const cancel = () => connection.close(); + signal?.addEventListener('abort', cancel, { once: true }); + try { + signal?.throwIfAborted(); + return await connection.request({ op: 'attach', pid }); + } finally { + signal?.removeEventListener('abort', cancel); + connection.close(); + } +} +export async function tryLongTermKeyScan( + dbPath: string, + pid: number, + progress?: (value: KeyScanProgress) => void, +): Promise { + const connection = await connectLongTermAuthorization(); + if (!connection) return null; + try { + const file = await open(dbPath, 'r'); + const sample = Buffer.alloc(5120); + try { + let position = 0; + while (position < sample.length) { + const { bytesRead } = await file.read(sample, position, sample.length - position, position); + if (!bytesRead) throw new Error('数据库头不足 5120 字节,无法扫描密钥'); + position += bytesRead; + } + } finally { + await file.close(); + } + return await connection.request( + { op: 'key-scan', pid, databaseSample: sample.toString('base64') }, + 10 * 60_000, + progress, + ); + } finally { + connection.close(); + } +} +export async function tryLongTermCaptureWorker(): Promise { + const connection = await connectLongTermAuthorization(); + if (!connection) return null; + return { + euid: 0, + request: (request, timeout) => connection.request(request, timeout), + dispose: () => connection.close(), + }; +} diff --git a/apps/desktop/src/main/authorization/full_mode.ts b/apps/desktop/src/main/authorization/full_mode.ts new file mode 100644 index 000000000..212a308b0 --- /dev/null +++ b/apps/desktop/src/main/authorization/full_mode.ts @@ -0,0 +1,77 @@ +import type { LongTermAuthorizationStatus } from '../../shared/long_term_authorization'; +import { fullModeAllowed, requestFullMode } from '../context/full_mode'; + +interface AuthorizationHost { + account: object | null; + accountIsStatic: boolean; + bootstrap: { userConfig: { getSettings(): { autoAttachQq: boolean } } } | null; + refreshRkeysNow(): Promise; +} + +/** 仅在助手真实就绪后为当前在线来源请求完整功能,不修改持久偏好。 */ +export class AuthorizationFullModeMonitor { + private stopped = false; + private timer: ReturnType | null = null; + + constructor( + private readonly host: () => AuthorizationHost, + private readonly readStatus: () => Promise, + ) {} + + async check(readStatus = this.readStatus): Promise { + const host = this.host(); + const source = host.account; + const status = await readStatus(); + if ( + this.stopped || + status.state !== 'ready' || + !source || + this.host() !== host || + host.account !== source || + host.accountIsStatic || + fullModeAllowed(host, source) + ) + return status; + try { + requestFullMode(host, source); + } catch { + // 退出清理期间指针可能尚未替换,失活来源不能被迟到检查重新启用。 + return status; + } + // 凭据实际就绪仍由现有监视器判断;助手连接成功不等于在线功能恢复。 + void host.refreshRkeysNow().catch(() => {}); + return status; + } + + start(): void { + if (this.stopped || this.timer) return; + this.timer = setTimeout(() => { + this.timer = null; + void this.poll(); + }, 0); + this.timer.unref(); + } + + private async poll(): Promise { + try { + const host = this.host(); + if (host.account && !host.accountIsStatic && !fullModeAllowed(host)) await this.check(); + } catch { + // 助手暂不可用时保持原有手动/临时授权路径,下轮重新检查。 + } finally { + if (!this.stopped) { + this.timer = setTimeout(() => { + this.timer = null; + void this.poll(); + }, 5000); + this.timer.unref(); + } + } + } + + stop(): void { + this.stopped = true; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + } +} diff --git a/apps/desktop/src/main/authorization/helper_path.ts b/apps/desktop/src/main/authorization/helper_path.ts new file mode 100644 index 000000000..bb3a9d0cd --- /dev/null +++ b/apps/desktop/src/main/authorization/helper_path.ts @@ -0,0 +1,19 @@ +import { posix, win32 } from 'node:path'; + +export function authorizationHelperPath({ + bundled, + packaged, + override, + platform, +}: { + bundled: () => string; + packaged: boolean; + override?: string; + platform: NodeJS.Platform; +}): string { + if (packaged || !override?.trim()) return bundled(); + const path = override.trim(); + if (!(platform === 'win32' ? win32 : posix).isAbsolute(path)) + throw new Error('WEQ_AUTHORIZATION_HELPER_PATH 必须是本机助手的绝对路径'); + return path; +} diff --git a/apps/desktop/src/main/authorization/install.ts b/apps/desktop/src/main/authorization/install.ts new file mode 100644 index 000000000..9b77e168c --- /dev/null +++ b/apps/desktop/src/main/authorization/install.ts @@ -0,0 +1,291 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { chmod, copyFile, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { resolveSudoPath } from '@weq/native'; +import { + type AuthorizationBuildInfo, + validateAuthorizationBuildInfo, + validateNativeBuildInfo, +} from '@weq/native/authorization.mjs'; +import type { LongTermAuthorizationStatus } from '../../shared/long_term_authorization'; +import { AUTHORIZATION_PROTOCOL } from '../../shared/long_term_authorization'; +import { requestSudoPassword } from '../sudo_prompt'; +import { + AuthorizationError, + configureLongTermAuthorization, + openLongTermAuthorization, +} from './client'; + +const execute = promisify(execFile); +interface HelperIdentity { + id: string; + root: string; + endpoint: string; + development: boolean; + buildInfo: AuthorizationBuildInfo; +} +type HelperProbe = (file: string, operation: 'identity' | 'build-info') => Promise; +const queryHelper: HelperProbe = async (file, operation) => + ( + await execute(file, [operation], { + timeout: 5000, + maxBuffer: 16_384, + windowsHide: true, + }) + ).stdout; +export function validateHelperBuildForOperation( + info: AuthorizationBuildInfo, + operation: 'install' | 'remove', +): void { + if (operation === 'install' && !info.isDev) validateNativeBuildInfo(info, { production: true }); +} +export async function fileDigest(path: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest('hex'); +} +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} +export function powershellQuote(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +export class AuthorizationInstaller { + private busy = false; + private identityCache: { key: string; value: Promise } | null = null; + constructor( + private readonly options: { + helper: () => string; + platform: NodeJS.Platform; + allowDevelopment?: boolean; + probe?: HelperProbe; + }, + ) { + configureLongTermAuthorization({ + endpoint: async () => (await this.identity()).endpoint, + development: async () => (await this.identity()).development, + }); + } + private async inspect(file: string): Promise { + const probe = this.options.probe ?? queryHelper; + const [identityText, buildText] = await Promise.all([ + probe(file, 'identity'), + probe(file, 'build-info'), + ]); + const identity = JSON.parse(identityText) as Omit; + const buildInfo = JSON.parse(buildText) as AuthorizationBuildInfo; + validateAuthorizationBuildInfo(buildInfo, `${this.options.platform}-${process.arch}`); + if ( + !identity.id || + !identity.root || + !identity.endpoint || + typeof buildInfo.isDev !== 'boolean' + ) + throw new AuthorizationError('权限助手诊断信息不完整,请更新组件', 'incompatible'); + if (buildInfo.isDev && !this.options.allowDevelopment) + throw new AuthorizationError('正式版不使用开发构建的权限助手', 'incompatible'); + if (buildInfo.isDev && (buildInfo.channel !== 'development' || identity.development !== true)) + throw new AuthorizationError('请重新编译支持隔离开发通道的权限助手', 'incompatible'); + return { ...identity, development: buildInfo.isDev, buildInfo }; + } + private async identity() { + const file = this.options.helper(); + const info = await stat(file, { bigint: true }); + const key = `${file}:${info.ino}:${info.size}:${info.mtimeNs}`; + if (this.identityCache?.key !== key) { + const value = this.inspect(file).catch((error: unknown) => { + if (this.identityCache?.key === key) this.identityCache = null; + throw error; + }); + this.identityCache = { key, value }; + } + return this.identityCache.value; + } + async status(): Promise { + const platform = this.options.platform; + let identity: HelperIdentity; + try { + if (platform === 'linux') await stat('/run/systemd/system'); + identity = await this.identity(); + } catch (error) { + return { + platform, + state: 'unavailable', + message: + error instanceof AuthorizationError || + (error instanceof Error && error.message.startsWith('WEQ_AUTHORIZATION_HELPER_PATH')) + ? error.message + : platform === 'linux' + ? '当前安装缺少原生权限助手或 systemd,仍可使用临时授权。' + : '当前安装未包含原生权限助手,请更新原生组件。', + }; + } + const base = { platform, development: identity.development }; + try { + const { connection, info } = await openLongTermAuthorization(); + connection.close(); + if (info.development !== undefined && info.development !== identity.development) + throw new AuthorizationError('助手运行通道与所选组件不一致,请重新检查', 'incompatible'); + const installed = await stat( + join( + identity.root, + this.options.platform === 'win32' + ? 'weq-authorization-helper.exe' + : 'weq-authorization-helper', + ), + ); + return { + ...base, + state: 'ready', + build: info.build, + installedBytes: installed.size, + message: identity.development + ? '独立开发助手已连接,授权与正式版隔离;仅提供当前系统用户的 QQ 内存读取与网卡抓包。' + : '原生权限助手已连接。仅授权当前系统用户的 QQ 内存读取与网卡抓包,不绕过系统保护。', + }; + } catch (error) { + if (error instanceof AuthorizationError && error.code === 'not-authorized') + return { ...base, state: 'not-installed', message: '当前系统用户未启用此通道的长期授权。' }; + try { + await stat(join(identity.root, 'manifest.json')); + } catch { + return { + ...base, + state: 'not-installed', + message: identity.development + ? '未启用独立开发助手;启用后不替换正式助手,权限不足时仍可临时授权。' + : '未启用长期授权,权限不足时仍使用临时授权。', + }; + } + return { + ...base, + state: 'needs-repair', + message: + error instanceof AuthorizationError && error.code === 'component-expired' + ? error.message + : '权限助手未响应或协议不兼容,请重新检查或修复授权。', + }; + } + } + private async elevate(helper: string, args: string[]): Promise { + if (this.options.platform === 'win32') { + const argumentsText = args.map((arg) => `"${arg.replaceAll('"', '\\"')}"`).join(' '); + const script = `$p=Start-Process -FilePath ${powershellQuote(helper)} -ArgumentList ${powershellQuote(argumentsText)} -Verb RunAs -Wait -PassThru; exit $p.ExitCode`; + await execute( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(script, 'utf16le').toString('base64'), + ], + { windowsHide: true, timeout: 10 * 60_000, maxBuffer: 16_384 }, + ); + } else if (this.options.platform === 'darwin') { + const command = [helper, ...args].map(shellQuote).join(' '); + await execute( + '/usr/bin/osascript', + ['-e', `do shell script ${JSON.stringify(command)} with administrator privileges`], + { timeout: 10 * 60_000, maxBuffer: 16_384 }, + ); + } else { + const password = await requestSudoPassword( + '安装或撤销长期授权', + '将修改 WeQ 原生权限助手的系统服务。管理员密码仅用于本次操作,不会保存。', + ); + if (!password) throw new Error('已取消长期授权操作'); + await new Promise((resolve, reject) => { + const child = spawn(resolveSudoPath(), ['-S', '-p', '', '--', helper, ...args], { + stdio: ['pipe', 'ignore', 'pipe'], + }); + let detail = ''; + child.stderr.on('data', (chunk: Buffer) => { + detail = (detail + chunk.toString('utf8')).slice(-4096); + }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error('长期授权操作超时')); + }, 10 * 60_000); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('close', (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error(detail || '长期授权操作未完成')); + }); + child.stdin.on('error', () => {}); + child.stdin.end(`${password}\n`); + }); + } + } + private async change( + operation: 'install' | 'remove', + expectedDevelopment?: boolean, + ): Promise { + if (this.busy) throw new Error('长期授权操作正在进行中'); + this.busy = true; + let temporary: string | null = null; + try { + if ((await this.status()).state === 'unavailable') + throw new Error('当前环境无法管理长期授权'); + const identity = await this.identity(); + if (expectedDevelopment !== undefined && expectedDevelopment !== identity.development) + throw new Error('助手通道已变化,请重新检查并确认'); + // 只暂存一个小型可执行文件,兼容 root 读不到 AppImage/FUSE 的宿主。 + temporary = await mkdtemp(join(tmpdir(), 'weq-authorization-')); + const helper = join(temporary, this.options.platform === 'win32' ? 'helper.exe' : 'helper'); + await copyFile(this.options.helper(), helper); + await chmod(helper, 0o755); + const staged = await this.inspect(helper); + if ( + staged.id !== identity.id || + staged.root !== identity.root || + staged.endpoint !== identity.endpoint || + staged.development !== identity.development + ) + throw new Error('操作期间助手通道发生变化,请重新检查后再试'); + validateHelperBuildForOperation(staged.buildInfo, operation); + let args = ['remove', identity.id]; + if (operation === 'install') { + const manifest = join(temporary, 'install.json'); + await writeFile( + manifest, + JSON.stringify({ + protocol: AUTHORIZATION_PROTOCOL, + identity: identity.id, + sha256: await fileDigest(helper), + }), + { mode: 0o600 }, + ); + args = ['install', manifest]; + } + await this.elevate(helper, args); + let status = await this.status(); + for ( + let attempt = 0; + operation === 'install' && status.state !== 'ready' && attempt < 15; + attempt++ + ) { + await new Promise((resolve) => setTimeout(resolve, 500)); + status = await this.status(); + } + return status; + } finally { + if (temporary) await rm(temporary, { recursive: true, force: true }); + this.busy = false; + } + } + install(expectedDevelopment?: boolean) { + return this.change('install', expectedDevelopment); + } + remove(expectedDevelopment?: boolean) { + return this.change('remove', expectedDevelopment); + } +} diff --git a/apps/desktop/src/main/authorization/ipc.ts b/apps/desktop/src/main/authorization/ipc.ts new file mode 100644 index 000000000..dbbe196c2 --- /dev/null +++ b/apps/desktop/src/main/authorization/ipc.ts @@ -0,0 +1,49 @@ +import { app, ipcMain } from 'electron'; +import { dirname, join } from 'node:path'; +import { resolveNtHelperPath } from '@weq/native'; +import { getMainWindow } from '../main_window'; +import { AuthorizationInstaller } from './install'; +import { authorizationHelperPath } from './helper_path'; +import { getAppContext } from '../context/app_context'; +import { AuthorizationFullModeMonitor } from './full_mode'; + +export function registerLongTermAuthorizationIpc(): void { + const installer = new AuthorizationInstaller({ + helper: () => + authorizationHelperPath({ + bundled: () => + join( + dirname(resolveNtHelperPath()), + process.platform === 'win32' + ? 'weq-authorization-helper.exe' + : 'weq-authorization-helper', + ), + packaged: app.isPackaged, + override: process.env.WEQ_AUTHORIZATION_HELPER_PATH, + platform: process.platform, + }), + platform: process.platform, + allowDevelopment: !app.isPackaged, + }); + const fullMode = new AuthorizationFullModeMonitor(getAppContext, () => installer.status()); + fullMode.start(); + app.once('will-quit', () => fullMode.stop()); + for (const operation of ['status', 'install', 'remove'] as const) { + ipcMain.handle(`authorization:${operation}`, (event, expectedDevelopment?: unknown) => { + const window = getMainWindow(); + if ( + !window || + event.sender !== window.webContents || + event.senderFrame !== window.webContents.mainFrame + ) + throw new Error('长期授权只能在本机 WeQ 主窗口管理'); + if (expectedDevelopment !== undefined && typeof expectedDevelopment !== 'boolean') + throw new Error('权限助手通道参数无效'); + return operation === 'status' + ? fullMode.check() + : operation === 'install' + ? fullMode.check(() => installer.install(expectedDevelopment)) + : installer.remove(expectedDevelopment); + }); + } +} diff --git a/apps/desktop/src/main/authorization/operations.ts b/apps/desktop/src/main/authorization/operations.ts new file mode 100644 index 000000000..2deb77195 --- /dev/null +++ b/apps/desktop/src/main/authorization/operations.ts @@ -0,0 +1,27 @@ +import type { NtHelperBinding } from '@weq/native'; +import { toCaptureFrameWire } from '../capture_protocol'; +import type { CaptureWorkerRequest } from '../capture_protocol'; + +/** 临时提权与长期助手共用 native 映射,避免维护第二套抓包协议。 */ +export async function dispatchCaptureOperation( + addon: Pick, + request: Omit, +): Promise { + switch (request.op) { + case 'start': + return addon.startCapture(request.pid, { + iface: request.iface, + port: request.port, + d2Key: request.d2key, + }); + case 'take': { + const batch = await addon.takeFrames(request.pid, { + cursor: request.cursor, + waitMs: request.waitMs, + }); + return { ...batch, frames: batch.frames.map(toCaptureFrameWire) }; + } + case 'stop': + return addon.stopCapture(request.pid); + } +} diff --git a/apps/desktop/src/main/authorization/protocol.ts b/apps/desktop/src/main/authorization/protocol.ts new file mode 100644 index 000000000..4d6f1895b --- /dev/null +++ b/apps/desktop/src/main/authorization/protocol.ts @@ -0,0 +1,13 @@ +import type { CaptureWorkerRequest } from '../capture_protocol'; +export const MAX_AUTHORIZATION_REPLY = 16 * 1024 * 1024; +export interface AuthorizationRequest extends Omit { + op: CaptureWorkerRequest['op'] | 'status' | 'attach' | 'key-scan'; + databaseSample?: string; +} +export interface AuthorizationHelperInfo { + protocol: number; + build: string; + validity?: 'valid' | 'expired'; + development?: boolean; + capabilities: string[]; +} diff --git a/apps/desktop/src/main/autostart.ts b/apps/desktop/src/main/autostart.ts new file mode 100644 index 000000000..90cc6145e --- /dev/null +++ b/apps/desktop/src/main/autostart.ts @@ -0,0 +1,204 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; +import { z } from 'zod'; +import type { AppAutostartStatus } from '@weq/service'; +import { legacyDaemonStateDir, retireLegacyDaemon, type RunCommand } from './legacy_daemon'; +import { AUTOSTART_ARG } from './startup_window'; + +const APP_ID = 'app.weq.desktop'; +const legacyPreference = z.object({ + version: z.literal(1), + enabled: z.boolean(), + gui_exe: z.string(), +}); +const migrationState = z.object({ + version: z.literal(1), + autostartMigrated: z.boolean(), + retired: z.boolean(), +}); +interface LoginOptions { + path?: string; + args?: string[]; + name?: string; +} +interface NativeLoginState { + openAtLogin: boolean; + status?: string; + executableWillLaunchAtLogin?: boolean; +} +interface Options { + platform: NodeJS.Platform; + home: string; + env: NodeJS.ProcessEnv; + configDir: string; + exePath: string; + supported: boolean; + native: { + get(options?: LoginOptions): NativeLoginState; + set(options: LoginOptions & { openAtLogin: boolean }): void; + }; + run?: RunCommand; + stopLegacy?: () => Promise; +} + +async function readOptional(path: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +export class AppAutostart { + private pending: Promise = Promise.resolve(); + private migrationError: string | undefined; + constructor(private readonly options: Options) {} + private serialize(action: () => Promise): Promise { + const task = this.pending.then(action); + this.pending = task.catch(() => {}); + return task; + } + private get desktopEntry() { + return join( + this.options.env.XDG_CONFIG_HOME ?? join(this.options.home, '.config'), + 'autostart', + `${APP_ID}.desktop`, + ); + } + private get loginOptions(): LoginOptions { + return { path: this.options.exePath, args: [AUTOSTART_ARG] }; + } + private async readStatus(): Promise { + const base = { + supported: this.options.supported, + enabled: false, + registered: false, + requiresApproval: false, + migrationError: this.migrationError, + }; + if (!this.options.supported) return base; + if (this.options.platform === 'linux') { + const text = await readOptional(this.desktopEntry); + const enabled = text !== null && !/^Hidden\s*=\s*true\s*$/m.test(text); + return { ...base, enabled, registered: text !== null }; + } + let current = this.options.native.get( + this.options.platform === 'win32' ? this.loginOptions : undefined, + ); + if (this.options.platform === 'win32' && !current.openAtLogin) { + const legacy = this.options.native.get({ path: this.options.exePath, args: [] }); + if (legacy.openAtLogin) current = legacy; + } + return { + ...base, + enabled: current.openAtLogin && current.executableWillLaunchAtLogin !== false, + registered: current.openAtLogin || current.status === 'requires-approval', + requiresApproval: current.status === 'requires-approval', + }; + } + async status(): Promise { + await this.pending; + return this.readStatus(); + } + private async apply(enabled: boolean): Promise { + if (!this.options.supported) throw new Error('请使用打包安装版设置 WeQ 登录自启'); + if (this.options.platform === 'linux') { + if (enabled) { + const exe = this.options.env.APPIMAGE || this.options.exePath; + if (!isAbsolute(exe) || /[\r\n=]/.test(exe) || exe.includes('\0')) + throw new Error('应用启动路径无效'); + // XDG 先解码字符串转义,再解码 Exec 引号;反斜杠需经过两层编码。 + const quoted = exe + .replaceAll('%', '%%') + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('`', '\\`') + .replaceAll('$', '\\$') + .replaceAll('\\', '\\\\'); + await mkdir( + join(this.options.env.XDG_CONFIG_HOME ?? join(this.options.home, '.config'), 'autostart'), + { recursive: true }, + ); + await writeFile( + this.desktopEntry, + `[Desktop Entry]\nType=Application\nName=WeQ\nExec="${quoted}" --ozone-platform=x11 --no-sandbox ${AUTOSTART_ARG}\nTerminal=false\n`, + { mode: 0o600 }, + ); + } else await rm(this.desktopEntry, { force: true }); + } else { + this.options.native.set({ + openAtLogin: enabled, + ...(this.options.platform === 'win32' ? { ...this.loginOptions, name: APP_ID } : {}), + }); + } + const status = await this.readStatus(); + if (enabled !== status.enabled && !(enabled && status.requiresApproval)) + throw new Error('系统未应用 WeQ 自启设置,请检查系统登录项'); + return status; + } + set(enabled: boolean): Promise { + return this.serialize(() => this.apply(enabled)); + } + + private async ensureLaunchMarker(): Promise { + if (this.options.platform === 'win32') { + if (this.options.native.get(this.loginOptions).openAtLogin) return; + const legacy = this.options.native.get({ path: this.options.exePath, args: [] }); + if (legacy.openAtLogin && legacy.executableWillLaunchAtLogin !== false) + await this.apply(true); + } else if (this.options.platform === 'linux') { + const text = await readOptional(this.desktopEntry); + if (text === null || /^Hidden\s*=\s*true\s*$/m.test(text)) return; + const exec = text.match(/^Exec=(.*)$/m)?.[1]; + if (exec && !exec.trimEnd().endsWith(` ${AUTOSTART_ARG}`)) await this.apply(true); + } + } + + migrateLegacy(): Promise { + return this.serialize(async () => { + if (!this.options.supported) return; + try { + const path = join(this.options.configDir, 'legacy-daemon-migration.json'); + const raw = await readOptional(path); + const state = + raw === null + ? { version: 1 as const, autostartMigrated: false, retired: false } + : migrationState.parse(JSON.parse(raw)); + await this.ensureLaunchMarker(); + // 旧版本回滚或手动启动可能重新注册旧服务;只迁一次偏好,启动时仍复核退役。 + if (!state.autostartMigrated) { + const legacy = await readOptional( + join( + legacyDaemonStateDir(this.options.platform, this.options.home, this.options.env), + 'weq-daemon.gui.json', + ), + ); + const previous = legacy === null ? null : legacyPreference.parse(JSON.parse(legacy)); + const current = await this.readStatus(); + if (previous?.enabled && !current.registered) await this.apply(true); + state.autostartMigrated = true; + await this.saveMigration(path, state); + } + await (this.options.stopLegacy ?? (() => retireLegacyDaemon(this.options)))(); + state.retired = true; + await this.saveMigration(path, state); + this.migrationError = undefined; + } catch (error) { + this.migrationError = error instanceof Error ? error.message : '旧后台迁移失败'; + throw error; + } + }); + } + private async saveMigration(path: string, state: z.infer): Promise { + await mkdir(this.options.configDir, { recursive: true }); + const tmp = `${path}.${randomUUID()}.tmp`; + try { + await writeFile(tmp, JSON.stringify(state), { flag: 'wx', mode: 0o600 }); + await rename(tmp, path); + } finally { + await rm(tmp, { force: true }); + } + } +} diff --git a/apps/desktop/src/main/call.ts b/apps/desktop/src/main/call.ts index 043d7900a..2541fda96 100644 --- a/apps/desktop/src/main/call.ts +++ b/apps/desktop/src/main/call.ts @@ -48,34 +48,66 @@ import { const logger = getLogger().child({ scope: 'call' }); +/** + * 补发缓存上限(帧)。 + * + * 实测对端关键帧间隔约 17 秒(15 fps ≈ 255 帧),所以 600 帧足够覆盖一整段。 + * 平均每帧 2 KB 时峰值约 1.2 MB —— 只为「订阅晚到」这一种情况准备,值得。 + * 超出说明码流异常(关键帧间隔极大),整段作废、等下一个关键帧。 + */ +const REPLAY_FRAMES = 600; + // ─────────────────────────── UDP ─────────────────────────── /** `node:dgram` 的 udp4 套接字适配成 `CallSession` 要的形状。 */ class NodeUdpTransport implements UdpTransport { private socket: dgram.Socket | null = null; + private p2pSocket: dgram.Socket | null = null; async bind(): Promise { - return new Promise((resolve, reject) => { - // 双栈套接字:视频通话的 P2P 直连地址是 **IPv6**(服务端下发的 bt=9 - // 里是 16 字节裸 IPv6),udp4 发不出去。`ipv6Only: false` 让它同时 - // 接受 IPv4 目标(Linux 上会自动走 ::ffff: 映射)。 - const socket = dgram.createSocket({ type: 'udp6', reuseAddr: false, ipv6Only: false }); - socket.once('error', reject); - socket.bind(0, () => { - socket.removeListener('error', reject); - this.socket = socket; - resolve(); + const open = (): Promise => + new Promise((resolve, reject) => { + const socket = dgram.createSocket({ type: 'udp6', reuseAddr: false, ipv6Only: false }); + socket.once('error', reject); + socket.bind(0, () => { + socket.removeListener('error', reject); + resolve(socket); + }); }); - }); + try { + const [control, p2p] = await Promise.all([open(), open()]); + this.socket = control; + this.p2pSocket = p2p; + } catch (error) { + this.close(); + throw error; + } + } + + /** + * 绑定后的本地端口。 + * + * `CallSession` 要用它填 `bt=25`(向服务端公告本端 P2P 端口)—— 公告的端口必须 + * 就是真正收发媒体这个 socket 的端口,否则对端打洞打不通。 + */ + localPort(): number { + if (!this.p2pSocket) return 0; + try { + return this.p2pSocket.address().port; + } catch { + // 已关闭 / 未绑定。 + return 0; + } } send(data: Uint8Array, port: number, address: string): void { - if (!this.socket) return; + const socket = [8000, 8001, 8800].includes(port) ? this.socket : this.p2pSocket; + if (!socket) return; // 直接拷一份:dgram 的回调可能在原缓冲被复写之后才跑。 const buffer = Buffer.from(data); // udp6 套接字发纯 IPv4 目标时,部分平台需要显式映射成 ::ffff: 形式。 const target = address.includes(':') ? address : `::ffff:${address}`; - this.socket.send(buffer, 0, buffer.length, port, target, (error) => { + socket.send(buffer, 0, buffer.length, port, target, (error) => { // 发送失败以前是静默吞掉的:地址不可达 / 套接字类型不匹配时 // 上层完全看不出「对方为什么收不到」。这里至少留一条日志。 if (error) { @@ -85,13 +117,17 @@ class NodeUdpTransport implements UdpTransport { } close(): void { - const socket = this.socket; + const sockets = [this.socket, this.p2pSocket].filter( + (socket): socket is dgram.Socket => !!socket, + ); this.socket = null; - if (!socket) return; - try { - socket.close(); - } catch { - // 已关闭 / 未绑定,忽略。 + this.p2pSocket = null; + for (const socket of sockets) { + try { + socket.close(); + } catch { + // 已关闭 / 未绑定,忽略。 + } } } @@ -113,14 +149,22 @@ class NodeUdpTransport implements UdpTransport { */ private pending: Array<(socket: dgram.Socket) => void> = []; private watch(register: (socket: dgram.Socket) => void): void { - if (this.socket) register(this.socket); - else this.pending.push(register); + if (!this.socket) { + this.pending.push(register); + return; + } + register(this.socket); + if (this.p2pSocket) register(this.p2pSocket); } /** 绑定完成后把排队的监听器挂上。 */ attach(): void { - if (!this.socket) return; - for (const register of this.pending) register(this.socket); + const sockets = [this.socket, this.p2pSocket].filter( + (socket): socket is dgram.Socket => !!socket, + ); + for (const register of this.pending) { + for (const socket of sockets) register(socket); + } this.pending = []; } } @@ -195,12 +239,20 @@ export interface StartCallParams { export type CallEvent = | { type: 'state'; call: ActiveCallInfo } | { type: 'audio'; pcm: Uint8Array } - /** 对端一帧视频(Annex B 裸流,只推基础层)。 */ + /** 对端一帧视频(Annex B 裸流)。 */ | { type: 'video'; hevc: Uint8Array; timestamp: number; keyframe: boolean; + /** + * 16 位帧序号 `((fidx - 1) << 8) | gidx`。 + * + * 渲染层要靠它判断「喂给解码器的帧是否连续」—— HEVC 的 P 帧依赖前序参考 + * 帧,跳过若干帧后继续喂 delta 帧会让硬解输出错乱的 YUV(表现为整屏纯绿), + * 且要等到下一个关键帧才恢复。见 useCallVideo 的 continuity 检查。 + */ + frameIndex: number; info: VideoStreamInfo | null; } /** 录音收尾完成(含内部收尾:对端挂断 / 被踢 / 崩溃)。 */ @@ -223,10 +275,19 @@ export class CallManager extends EventEmitter { /** 视频编码器(只有视频通话才非 null)。 */ private videoEncoder: NativeVideoEncoder | null = null; - /** 上一帧是否基础层(用于「基础层优先」的取舍)。 */ - private lastFrameWasBase = false; - /** 最近收到的一个关键帧(新订阅者补发用)。 */ - private lastKeyframe: VideoFramePayload | null = null; + /** + * 从**最近一个关键帧**开始的一段连续帧(新订阅者补发用)。 + * + * ⚠️ **只补关键帧是不够的。** 2026-10-26 实测:渲染层订阅比收帧晚约 1.9 秒, + * 补发关键帧(帧 0)之后紧接着喂实时帧(帧 29),中间 1~28 缺失。HEVC 的 P 帧 + * 依赖前序参考帧,硬解拒绝这些 delta 后内部状态被污染,输出错乱的 YUV —— + * 整屏纯绿,直到下一个关键帧(约 15 秒)才恢复。 + * + * 所以这里缓存「关键帧 + 其后所有帧」的连续片段,订阅时整段补发,渲染层就能 + * 从头连续解码。上限 {@link REPLAY_FRAMES} 帧,超出就丢最旧的(那些帧的参考 + * 链已经断了,补发也没用)。 + */ + private replayBuffer: VideoFramePayload[] = []; /** 当前这通电话的录音设置(发起时取一次,通话中改设置不影响进行中的录音)。 */ private recordingConfig: CallRecordingConfig = { mode: 'mixed', systemAudio: false, dir: null }; private recorder: CallRecorder | null = null; @@ -253,14 +314,16 @@ export class CallManager extends EventEmitter { } /** - * 取最近的关键帧,供**新建立**的渲染层订阅补发。 + * 取一段**从关键帧开始、帧序号连续**的帧,供新建立的渲染层订阅补发。 * * 渲染层是在「对端开始发视频」的状态推送之后才订阅 `onCallVideo` 的,而首个 - * 关键帧通常已经过去。没有关键帧起解,后面的 delta 帧全解不出来 —— 所以 - * 订阅时先补这一帧。 + * 关键帧通常已经过去。**只补关键帧不够**:跳帧会让硬解输出错乱(整屏纯绿), + * 必须把关键帧之后的帧一并补上,让渲染层从头连续解码。详见 `replayBuffer`。 + * + * 返回空数组表示还没有可用的起点(还没收到过关键帧)。 */ - latestKeyframe(): VideoFramePayload | null { - return this.lastKeyframe; + replayFrames(): VideoFramePayload[] { + return this.replayBuffer; } /** 发起一路通话。已有通话在跑时直接报错,由界面提示先挂断。 */ @@ -290,6 +353,8 @@ export class CallManager extends EventEmitter { await transport.bind(); transport.attach(); }, + // 必须透传:会话靠它填 bt=25 的 P2P 端口,漏掉这一行整条 P2P 通路就断了。 + localPort: () => transport.localPort(), send: (data, port, address) => transport.send(data, port, address), close: () => transport.close(), onMessage: (handler) => transport.onMessage(handler), @@ -328,25 +393,21 @@ export class CallManager extends EventEmitter { this.emit('event', { type: 'audio', pcm } satisfies CallEvent); }); session.onVideo((frame) => { - // 只推基础层:增强层依赖基础层,单独解会花屏。 + // **每一帧都推,不要按 fidx 过滤。** // - // 但实测对端会在基础层之外再发 fidx=2 的增强层,而**基础层不是每帧都有**—— - // 一旦对端把画面切成纯增强层(长时间静止后重新编码),全丢就会永久黑屏。 - // 所以:基础层优先,没有基础层时退回增强层(解码器能解多少算多少, - // 花屏好过没画面)。 + // 早先这里按「fidx === 1 才是基础层」丢弃 fidx>=2 的帧,前提是错的:fidx + // 其实是**16 位帧序号的高字节**(每 256 帧 +1),不是 SVC 层号。于是通话 + // 进行到第 256 帧(10 fps 下约 25 秒)之后,`isBaseLayer` 恒为 false, + // 而标记又停在 true 且不再更新 —— 后续每一帧都被静默丢掉,画面永久冻结。 + // 真机日志里 `g=249/f=1 → g=43/f=2` 的序号连续性就是证据。 if (!frame.hevc) return; - // 增强层只在没有基础层可用时才推 —— 见上面的注释。 - if (!frame.isBaseLayer && this.lastFrameWasBase) return; - this.lastFrameWasBase = frame.isBaseLayer; - // 缓存最近一个关键帧:渲染层的解码订阅是在「对端开始发视频」的状态推送 - // **之后**才建立的,而首个关键帧往往已经过去 —— 后面的 delta 帧没有 - // 关键帧起解,画面永远出不来。新订阅先用这份补一帧。 - if (frame.isKeyframe) this.lastKeyframe = frame; + this.rememberForReplay(frame); this.emit('event', { type: 'video', hevc: frame.hevc, timestamp: frame.timestamp, keyframe: frame.isKeyframe, + frameIndex: frame.frameIndex, info: frame.info, } satisfies CallEvent); }); @@ -643,12 +704,43 @@ export class CallManager extends EventEmitter { this.transport?.close(); this.videoEncoder?.close(); this.videoEncoder = null; - this.lastKeyframe = null; + this.replayBuffer = []; this.session = null; this.transport = null; this.info = null; } + /** + * 维护「从关键帧开始的一段连续帧」缓存,供新订阅补发。 + * + * 见 {@link replayBuffer} 的注释:只补关键帧会让渲染层跳帧喂入,硬解输出 + * 错乱(整屏纯绿)。所以: + * + * - 遇到关键帧 → 清空重来(这是新的参考链起点); + * - 帧序号不连续 → 同样清空(参考链已断,之前的帧补发也没用); + * - 其余帧追加,超过上限丢最旧的。 + */ + private rememberForReplay(frame: VideoFramePayload): void { + const last = this.replayBuffer[this.replayBuffer.length - 1]; + if (frame.isKeyframe) { + this.replayBuffer = [frame]; + return; + } + if (!last) return; // 还没见过关键帧,先不缓存 + // 16 位序号会回绕,按模判断是否正好 +1。 + const continuous = (frame.frameIndex - last.frameIndex + 65536) % 65536 === 1; + if (!continuous) { + this.replayBuffer = []; + return; + } + this.replayBuffer.push(frame); + // 超出上限时丢**最旧的 delta**、保留关键帧?不行 —— 关键帧之后的帧必须 + // 连续才有意义。这里改为整段作废等下一个关键帧:对端关键帧间隔实测约 + // 17 秒,缓存峰值 255 帧 × 平均 2 KB ≈ 500 KB,完全可以一直留着。 + // 上限只是防止异常码流(关键帧间隔极大)把内存吃爆。 + if (this.replayBuffer.length > REPLAY_FRAMES) this.replayBuffer = []; + } + /** * `AVSDKPlugin` 和 `wrapper.node` 同在 QQ 的 `resources/app` 下; * linux 的插件在它下面的 `avsdk/` 子目录,`nt_helper` 会自己找。 diff --git a/apps/desktop/src/main/call_video_encoder.ts b/apps/desktop/src/main/call_video_encoder.ts index 57c52c9b1..59c7b10b9 100644 --- a/apps/desktop/src/main/call_video_encoder.ts +++ b/apps/desktop/src/main/call_video_encoder.ts @@ -68,7 +68,9 @@ export class NativeVideoEncoder implements CallVideoEncoder { this.width = options.width ?? 640; this.height = options.height ?? 360; this.fps = options.fps ?? 15; - this.bitrateKbps = options.bitrateKbps ?? 400; + // 30 kbps:与 QQ 本体对齐(实测静态 6 kbps、运动峰值 26 kbps)。 + // 旧的 400 kbps 会让每帧涨到 3KB+、拆成 3~28 个 UDP 包,丢一片整帧报废。 + this.bitrateKbps = options.bitrateKbps ?? 30; } private readonly fps: number; @@ -150,12 +152,21 @@ export class NativeVideoEncoder implements CallVideoEncoder { ); // 编码器还在 lookahead 里攒帧时是 null,下一拍再取。 if (!result) return null; - this.dumpAnnexB(new Uint8Array(result.data)); - return new Uint8Array(result.data); + const out = new Uint8Array(result.data); + this.dumpAnnexB(out); + return out; } info(): VideoStreamInfo { - return { width: this.width, height: this.height, profile: 1, codec: 'hevc' }; + return { + width: this.width, + height: this.height, + profile: 1, + codec: 'hevc', + // `bt=77` 视频参数公告要用:对端按这里声明的规格准备解码。 + fps: this.fps, + bitrateKbps: this.bitrateKbps, + }; } close(): void { diff --git a/apps/desktop/src/main/call_video_ipc.ts b/apps/desktop/src/main/call_video_ipc.ts new file mode 100644 index 000000000..608c5701d --- /dev/null +++ b/apps/desktop/src/main/call_video_ipc.ts @@ -0,0 +1,46 @@ +/** + * 通话视频帧的二进制 IPC 接收端(主进程)。 + * + * 与 `shared/call_video_ipc.ts` 配对:渲染层用 `ipcRenderer.send` 把 I420 平面 + * 直接推过来(结构化克隆,不经 JSON),这里转交给当前通话的编码器。 + * + * 为什么不用 tRPC:见 `shared/call_video_ipc.ts` 的说明 —— 一帧 345 KB 的 + * `Uint8Array` 经 superjson 会变成 1.2 MB 的十进制文本,单帧开销 30 ms, + * 15 fps 下渲染层主线程被吃满,两端画面都卡。 + * + * 只注册一次;没有进行中的通话时直接丢弃(视频帧不需要排队,过期的画面没有意义)。 + */ + +import { ipcMain } from 'electron'; +import { getLogger } from '@weq/service'; +import { CALL_VIDEO_FRAME_CHANNEL, type CallVideoFrame } from '../shared/call_video_ipc'; +import { findCallManager } from './call_registry'; + +const logger = getLogger().child({ scope: 'call-video-ipc' }); + +/** 尺寸校验用:一帧 I420 的 Y 平面必须恰好 `w*h`。 */ +function asBytes(value: unknown): Uint8Array | null { + if (value instanceof Uint8Array) return value; + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + if (value instanceof ArrayBuffer) return new Uint8Array(value); + return null; +} + +export function registerCallVideoIpc(): void { + ipcMain.on(CALL_VIDEO_FRAME_CHANNEL, (_event, raw: unknown) => { + if (!raw || typeof raw !== 'object') return; + const frame = raw as Partial; + const y = asBytes(frame.y); + const u = asBytes(frame.u); + const v = asBytes(frame.v); + if (!y || !u || !v) return; + + const manager = findCallManager(); + if (!manager) return; + // 主进程侧还会再校验一次尺寸(见 NativeVideoEncoder.pushFrame)。 + manager.pushVideoFrame(y, u, v); + }); + logger.debug('通话视频帧 IPC 已注册'); +} diff --git a/apps/desktop/src/main/capture_elevation.ts b/apps/desktop/src/main/capture_elevation.ts index 5f8806299..60e3993b4 100644 --- a/apps/desktop/src/main/capture_elevation.ts +++ b/apps/desktop/src/main/capture_elevation.ts @@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url'; import { linuxSudoErrorHint, resolveSudoPath } from '@weq/native'; import { getLogger } from '@weq/service'; import { requestSudoPassword } from './sudo_prompt'; +import { tryLongTermCaptureWorker } from './authorization/client'; import type { CaptureWorkerHello, CaptureWorkerReply, @@ -235,6 +236,10 @@ function connectWorker( export async function startElevatedCaptureWorker( ntHelperPath: string, ): Promise { + const persistent = await tryLongTermCaptureWorker(); + if (persistent) return persistent; + if (process.platform === 'win32') + throw new Error('长期授权不可用,请在设置中修复或使用现有 Windows 抓包授权。'); let password = ''; if (!(await sudoCredentialsCached())) { const entered = await requestSudoPassword( diff --git a/apps/desktop/src/main/capture_ownership.ts b/apps/desktop/src/main/capture_ownership.ts new file mode 100644 index 000000000..a9b79bedb --- /dev/null +++ b/apps/desktop/src/main/capture_ownership.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto'; + +export interface CaptureRequest { + uin: string; + id: string; +} + +/** 同 pid 的替换/停止串行;长轮询只检查身份,不阻塞停止。 */ +export class CaptureOwnership { + private readonly requests = new Map(); + private readonly sessions = new Map(); + private readonly queues = new Map>(); + + constructor(private readonly makeId: () => string = randomUUID) {} + + begin(uin: string): CaptureRequest { + const request = { uin, id: this.makeId() }; + this.requests.set(uin, request); + return request; + } + + cancel(request: CaptureRequest): void { + if (this.requests.get(request.uin)?.id === request.id) this.requests.delete(request.uin); + } + + reset(): void { + this.requests.clear(); + this.sessions.clear(); + } + + owns(pid: number, id: string): boolean { + return this.sessions.get(pid)?.id === id; + } + + private current(request: CaptureRequest): boolean { + return this.requests.get(request.uin)?.id === request.id; + } + + private exclusive(pid: number, operation: () => Promise): Promise { + const previous = this.queues.get(pid) ?? Promise.resolve(); + const result = previous.then(operation, operation); + const settled = result.then( + () => undefined, + () => undefined, + ); + this.queues.set(pid, settled); + void settled.then(() => { + if (this.queues.get(pid) === settled) this.queues.delete(pid); + }); + return result; + } + + start( + request: CaptureRequest, + pid: number, + start: () => Promise, + dispose: () => Promise, + ): Promise { + return this.exclusive(pid, async () => { + if (!this.current(request)) throw new Error('抓包请求已失效,请重新开始'); + const previous = this.sessions.get(pid); + if (previous && previous.id !== request.id) this.cancel(previous); + this.sessions.set(pid, request); + try { + const value = await start(); + if (!this.current(request)) throw new Error('抓包请求已被新的请求替换'); + return value; + } catch (error) { + this.sessions.delete(pid); + this.cancel(request); + await dispose().catch(() => undefined); + throw error; + } + }); + } + + async take(pid: number, id: string | undefined, read: () => Promise): Promise { + const check = () => { + if (id && this.sessions.get(pid)?.id !== id) throw new Error('抓包会话已结束或被替换'); + }; + check(); + const result = await read(); + check(); + return result; + } + + stop(pid: number, id: string | undefined, stop: () => Promise): Promise { + return this.exclusive(pid, async () => { + const session = this.sessions.get(pid); + if (id && session?.id !== id) return null; + this.sessions.delete(pid); + if (session) this.cancel(session); + return stop(); + }); + } +} diff --git a/apps/desktop/src/main/capture_session.ts b/apps/desktop/src/main/capture_session.ts index cd1af920f..70cf24ccb 100644 --- a/apps/desktop/src/main/capture_session.ts +++ b/apps/desktop/src/main/capture_session.ts @@ -31,6 +31,8 @@ import { type CaptureStatsWire, } from './capture_protocol'; import { startElevatedCaptureWorker, type ElevatedCaptureWorker } from './capture_elevation'; +import { CaptureOwnership } from './capture_ownership'; +import { connectLongTermAuthorization } from './authorization/client'; const logger = getLogger().child({ scope: 'capture-session' }); @@ -194,6 +196,7 @@ export async function prepareCaptureSession( * pid 记的,而取帧 / 停会话只带 pid,所以只能按 pid 找回子进程。 */ const elevatedCaptures = new Map(); +const captureOwnership = new CaptureOwnership(); /** 取(或起)某 pid 的提权抓包子进程;需要密码时由它自己弹框。 */ export async function acquireElevatedCaptureWorker(pid: number): Promise { @@ -206,6 +209,7 @@ export async function acquireElevatedCaptureWorker(pid: number): Promise { - const platform = requirePlatform(); - const support = probeCaptureBackend(); - if (!support.available) { - throw new Error(support.hint || '抓包后端不可用'); - } - const { pid, d2Key } = await prepareCaptureSession(uin); + const request = captureOwnership.begin(uin); + try { + const platform = requirePlatform(); + const support = probeCaptureBackend(); + if (!support.available) { + throw new Error(support.hint || '抓包后端不可用'); + } + const { pid, d2Key } = await prepareCaptureSession(uin); + let persistentAvailable = false; + if (process.platform === 'win32' && !support.elevated) { + const connection = await connectLongTermAuthorization(); + persistentAvailable = connection !== null; + connection?.close(); + } + return await captureOwnership.start( + request, + pid, + async () => { + if (support.elevated || (process.platform === 'win32' && !persistentAvailable)) { + const session = await platform.native.ntHelper.startCapture(pid, { + iface: opts.iface, + port: opts.port, + // ⚠️ native 的字段名是 `d2Key`(napi 从 Rust `d2key` 转来);写成小写 + // `d2key` 会被静默忽略 → 密文全部解不开。 + d2Key, + }); + return { pid, sessionId: request.id, elevated: false, hasD2Key: Boolean(d2Key), session }; + } - if (support.elevated || process.platform === 'win32') { - const session = await platform.native.ntHelper.startCapture(pid, { - iface: opts.iface, - port: opts.port, - // ⚠️ native 的字段名是 `d2Key`(napi 从 Rust `d2key` 转来);写成小写 - // `d2key` 会被静默忽略 → 密文全部解不开。 - d2Key, - }); - return { pid, elevated: false, hasD2Key: Boolean(d2Key), session }; + const worker = await acquireElevatedCaptureWorker(pid); + const session = await worker.request({ + op: 'start', + pid, + iface: opts.iface, + port: opts.port, + d2key: d2Key, + }); + return { pid, sessionId: request.id, elevated: true, hasD2Key: Boolean(d2Key), session }; + }, + () => stopCaptureBackend(pid), + ); + } catch (error) { + captureOwnership.cancel(request); + throw error; } - - const worker = await acquireElevatedCaptureWorker(pid); - const session = await worker.request({ - op: 'start', - pid, - iface: opts.iface, - port: opts.port, - d2key: d2Key, - }); - return { pid, elevated: true, hasD2Key: Boolean(d2Key), session }; } /** 拉一批新增帧(自动路由到提权子进程 / 本进程),hex 形态。 */ export async function takeAccountFrames( pid: number, - opts: { cursor?: number; waitMs?: number } = {}, + opts: { cursor?: number; waitMs?: number; sessionId?: string } = {}, ): Promise { - const elevated = elevatedCaptures.get(pid); - if (elevated) { - return await elevated.request({ - op: 'take', - pid, + return captureOwnership.take(pid, opts.sessionId, async () => { + const elevated = elevatedCaptures.get(pid); + if (elevated) { + return await elevated.request({ + op: 'take', + pid, + cursor: opts.cursor, + waitMs: opts.waitMs, + }); + } + const platform = requirePlatform(); + const batch = await platform.native.ntHelper.takeFrames(pid, { cursor: opts.cursor, waitMs: opts.waitMs, }); - } - const platform = requirePlatform(); - const batch = await platform.native.ntHelper.takeFrames(pid, { - cursor: opts.cursor, - waitMs: opts.waitMs, + return { + frames: batch.frames.map(toCaptureFrameWire), + nextCursor: batch.nextCursor, + dropped: batch.dropped, + }; }); - return { - frames: batch.frames.map(toCaptureFrameWire), - nextCursor: batch.nextCursor, - dropped: batch.dropped, - }; } /** 停止抓包并释放会话(幂等:会话已不存在时返回 null)。 */ -export async function stopAccountCapture(pid: number): Promise { +export async function stopAccountCapture( + pid: number, + sessionId?: string, +): Promise { + return captureOwnership.stop(pid, sessionId, () => stopCaptureBackend(pid)); +} + +async function stopCaptureBackend(pid: number): Promise { const elevated = elevatedCaptures.get(pid); if (elevated) { elevatedCaptures.delete(pid); diff --git a/apps/desktop/src/main/capture_worker.ts b/apps/desktop/src/main/capture_worker.ts index c6db18c97..c6d9c44ea 100644 --- a/apps/desktop/src/main/capture_worker.ts +++ b/apps/desktop/src/main/capture_worker.ts @@ -29,32 +29,21 @@ import { randomBytes } from 'node:crypto'; import { createRequire } from 'node:module'; import { createServer, type Server, type Socket } from 'node:net'; import { dirname } from 'node:path'; -import type { CapturedFrame } from '@weq/native'; -import { - toCaptureFrameWire, - type CaptureSessionWire, - type CaptureStatsWire, - type CaptureWorkerHello, - type CaptureWorkerReply, - type CaptureWorkerRequest, +import type { NtHelperBinding } from '@weq/native'; +import { dispatchCaptureOperation } from './authorization/operations'; +import type { + CaptureWorkerHello, + CaptureWorkerReply, + CaptureWorkerRequest, } from './capture_protocol'; const requireFn = createRequire(__filename); /** 本进程用到的 nt_helper 出口(其余不碰)。 */ -interface CaptureAddon { - getInitStatus(): number; - probeCaptureSupport(): { available: boolean; backend: string; elevated: boolean; hint: string }; - startCapture( - pid: number, - options?: { iface?: string; port?: string; d2Key?: string } | null, - ): Promise; - takeFrames( - pid: number, - options?: { cursor?: number; waitMs?: number } | null, - ): Promise<{ frames: CapturedFrame[]; nextCursor: number; dropped: number }>; - stopCapture(pid: number): Promise; -} +type CaptureAddon = Pick< + NtHelperBinding, + 'getInitStatus' | 'probeCaptureSupport' | 'startCapture' | 'takeFrames' | 'stopCapture' +>; function errText(e: unknown): string { return e instanceof Error ? e.message : String(e); @@ -111,37 +100,10 @@ async function main(): Promise { } async function dispatch(req: CaptureWorkerRequest): Promise { - switch (req.op) { - case 'start': { - const session = await addon.startCapture(req.pid, { - iface: req.iface, - port: req.port, - // native 的字段名是 `d2Key`(napi 从 Rust `d2key` 转来);写成 `d2key` - // 会被静默忽略 → 密文全部解不开。线上协议那层(req.d2key)保持小写。 - d2Key: req.d2key, - }); - activePids.add(req.pid); - return session; - } - case 'take': { - const batch = await addon.takeFrames(req.pid, { - cursor: req.cursor, - waitMs: req.waitMs, - }); - return { - frames: batch.frames.map(toCaptureFrameWire), - nextCursor: batch.nextCursor, - dropped: batch.dropped, - }; - } - case 'stop': { - const stats = await addon.stopCapture(req.pid); - activePids.delete(req.pid); - return stats; - } - default: - throw new Error(`未知的抓包 worker 请求:${String((req as { op?: unknown }).op)}`); - } + const result = await dispatchCaptureOperation(addon, req); + if (req.op === 'start') activePids.add(req.pid); + if (req.op === 'stop') activePids.delete(req.pid); + return result; } async function shutdown(): Promise { diff --git a/apps/desktop/src/main/chat_image_response.ts b/apps/desktop/src/main/chat_image_response.ts new file mode 100644 index 000000000..1b3442dbf --- /dev/null +++ b/apps/desktop/src/main/chat_image_response.ts @@ -0,0 +1,33 @@ +import { readFile, stat } from 'node:fs/promises'; +import { createImagePreviewer } from './image_preview'; +import { fileResponse } from './file_response'; + +const preview = createImagePreviewer({ preserveAnimation: true }); + +/** 气泡缩图复用受限解码器;动画保留播放,原图仍由灯箱独立请求。 */ +export async function chatImageResponse( + path: string, + request?: Request, + check?: () => void, +): Promise { + try { + const output = await preview(async () => { + check?.(); + const info = await stat(path); + if (info.size > 32 * 1024 * 1024) throw new Error('preview input size'); + return readFile(path); + }); + check?.(); + return new Response(new Uint8Array(output), { + headers: { + 'Content-Type': 'image/webp', + 'Content-Length': String(output.byteLength), + 'Cache-Control': 'no-cache', + }, + }); + } catch { + check?.(); + // 动画、坏图或超限输入沿用原图回退,不静默替换成静态首帧。 + return fileResponse(path, request); + } +} diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index abaecea60..111d8900d 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -19,13 +19,15 @@ */ import { EventEmitter } from 'node:events'; +import { createDbChangeNotification } from '../db_change_notification'; import { initBackupContext, resetBackupContextForRecovery } from './backup_context'; +import { clearFullMode, fullModeAllowed } from './full_mode'; import { resolveBackupLocation, backupMediaRoots } from '../static_backup'; import { VipBadgeService } from '../vip_badge'; import { existsSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { loadNativeSafe } from '@weq/native'; -import { AnnualReportIndexDb } from '@weq/db'; +import { AnnualReportIndexDb, withDatabasePriority, waitForDatabaseQuiet } from '@weq/db'; import { QQMailService, type MailNotice } from '@weq/service/mail'; import { createWin32Platform, @@ -37,12 +39,12 @@ import { } from '@weq/platform'; import { startMcpServer, stopMcpServer } from '../mcp/server'; import { startLogRetention } from '../log_retention'; -import { ensureDaemonRunning, startDaemonHttp } from '../daemon/runtime'; +import { assistantResourceServer } from '../weq_assistant/server'; import { publishWeqAssistantDocroot } from '../weq_assistant/publish'; import { createReportQzoneCapability } from '../report_qzone'; import { refreshWeqStats, setWeqStats, statsCachePath } from '../weq_assistant/stats'; import { ensureDefaultTweets, tweetsStorePath } from '../weq_assistant/tweets'; -import { aiToolSpecs, runAssistantTool } from '../mcp/openai_tools'; +import { assistantToolSpecs, runAssistantTool } from '../mcp/openai_tools'; import { bindAccountTools, invalidateAccountTools } from '../mcp/account_scope'; import { cancelAssistantRuns } from '../mcp/assistant_bus'; import { getExternalMcpHub, disposeExternalMcp } from '../mcp/external'; @@ -55,6 +57,7 @@ import type { IntentLocalEnginePort } from '@weq/service'; import { resolveLocalOcrFilterModel, runLocalOcrFile, runLocalOcrFilterFile } from '../ocr/local'; import { OnlineImageOcr } from '../ocr/online'; import { createLinuxAttachHook } from '../attach_elevation'; +import { tryLongTermAttach } from '../authorization/client'; import { accountConfigId, AnnualReportService, @@ -139,13 +142,14 @@ import { ChatImageOcrService, InteractionService, GroupModerationService, + LocationShareService, AccountWriteService, DbWatchService, checkAccountDatabaseHealth, createNtMsgDbHook, + waitForUnreadWrites, formatDbHealthFailures, writeDbHealthReport, - daemonHttpStop, initLogger, getLogger, getLogDir, @@ -250,25 +254,11 @@ export function rememberAccountUid(uin: string, uid: string): void { if (uin && uid) uidRegistry.set(uin, uid); } -/** Trailing debounce — coalesces a burst of calls into one after `ms` idle. */ -function trailingDebounce( - fn: (...args: A) => void, - ms: number, -): (...args: A) => void { - let timer: ReturnType | undefined; - return (...args: A): void => { - if (timer) clearTimeout(timer); - timer = setTimeout(() => { - timer = undefined; - fn(...args); - }, ms); - }; -} - /** One polling loop for the whole process; we (un)mount the active db on it. */ const dbWatch = new DbWatchService(); /** Handle for the currently-watched account db, if any. */ let dbWatchHandle: DbWatchHandle | null = null; +let dbChangeNotification: ReturnType> | null = null; /** SSE 消息推送服务(随账号开关),null = 未启用 / 无账号。 */ let ssePush: SsePushService | null = null; /** 群关键词提醒匹配器(随账号打开常驻),null = 无账号。 */ @@ -313,6 +303,7 @@ export interface IntentSenseLocalEngineControl extends IntentLocalEnginePort { modelDir(): string; isModelDownloaded(): boolean; download(options: { + modelId?: string; mirror: boolean; onProgress?: (p: IntentSenseModelProgress) => void; }): Promise; @@ -376,8 +367,15 @@ const dbHealthFailureAt = new WeakMap(); * 再在后台把索引补上。已经建好的库上这个调用是 ~9ms 的 no-op。 */ let annualIndexWarmup: ReturnType | null = null; +let annualIndexController: AbortController | null = null; /** 延迟多久开始建索引 —— 避开开账号瞬间那一批首屏查询。 */ const ANNUAL_INDEX_WARMUP_DELAY_MS = 3000; +function cancelAnnualReportWarmup(): void { + if (annualIndexWarmup) clearTimeout(annualIndexWarmup); + annualIndexWarmup = null; + annualIndexController?.abort(); + annualIndexController = null; +} /** * Mount the nt_msg.db watcher for `session` (idempotent — no-op if already @@ -388,25 +386,28 @@ const ANNUAL_INDEX_WARMUP_DELAY_MS = 3000; */ function mountDbWatch(session: AccountSession): void { if (dbWatchHandle) return; - const emitChanged = trailingDebounce((file: DbChange) => { + const notification = createDbChangeNotification((file: DbChange) => { if (getAppContext().account !== session) return; dbEventBus.emit('changed', file); - }, 200); + }); + dbChangeNotification = notification; dbWatchHandle = dbWatch.mount( createNtMsgDbHook(session, { - onDbChanged: emitChanged, - onNewMessages: (change: NewMessages) => { + onDbChanged: notification.request, + onNewMessages: async (change: NewMessages) => { const ctx = getAppContext(); if (ctx.account !== session) return; if (ctx.account === session) { try { - ctx.services?.unreadInfo.observeNewMessages(change); + await ctx.services?.unreadInfo.observeNewMessages(change); } catch (error) { console.error('[unread] 保存本地未读状态失败', error); + throw error; } } + if (ctx.account !== session) return; dbEventBus.emit('new', change); - emitChanged(change.file); + notification.request(change.file); }, }), ); @@ -414,6 +415,8 @@ function mountDbWatch(session: AccountSession): void { /** Stop watching the current account db, if any. */ function unmountDbWatch(): void { + dbChangeNotification?.dispose(); + dbChangeNotification = null; dbWatchHandle?.unmount(); dbWatchHandle = null; } @@ -499,7 +502,7 @@ async function createRoamingBackfill( checkOnline: () => { const record = accountConfig.getRecord(); if ( - userConfig.getSettings().autoAttachQq === false || + !fullModeAllowed(getAppContext(), session) || !record?.qqOnline || !record.qqPid || record.uin !== session.context.uin @@ -556,19 +559,18 @@ export function closeSalvageConnections(ctx: AppContext): number { * 的后台,用户还没走到报告页,这次建索引就是「白捡」的。 * * 三个刻意的选择: - * • **延迟 {@link ANNUAL_INDEX_WARMUP_DELAY_MS}**:避开开账号瞬间那一批首屏查询 - * (会话列表 / 未读 / 联系人),别让建索引跟它们抢连接。 + * • 延迟仅是首次检查;缺失索引逐个等待数据库队列为空且交互安静五秒, + * 再以后台优先级创建,不能将定时器到点当作空闲。已执行的 DDL 不可抢占。 * • **best-effort**:静态快照、只读目录、无写权限都会抛错,一律只记日志 —— * 报告不该因为装不上索引就打不开,只是慢一点。 - * • **幂等**:已建好的库上是 ~9ms 的 no-op,所以每次开账号都可以无脑跑一次。 + * • **幂等**:先检查已有索引,不重复提交 DDL;两个缺失索引之间再次让出。 * * 返回前会核对 session 仍是当前账号 —— 期间用户切了账号就整个丢弃。 */ function warmAnnualReportIndex(ctx: AppContext, session: AccountSession, platform: Platform): void { - if (annualIndexWarmup) { - clearTimeout(annualIndexWarmup); - annualIndexWarmup = null; - } + cancelAnnualReportWarmup(); + const controller = new AbortController(); + annualIndexController = controller; const logger = getLogger().child({ scope: 'annual-report-index' }); annualIndexWarmup = setTimeout(() => { annualIndexWarmup = null; @@ -580,23 +582,37 @@ function warmAnnualReportIndex(ctx: AppContext, session: AccountSession, platfor key: session.context.dbKey, algo: algoFor(session.context, session.msgDbPath), }); - try { - const installed = await db.ensure(); - logger.info('annual report indexes ready', { - event: 'annual-index-ready', + const installed = await withDatabasePriority( + 'background', + () => + db.ensure({ + beforeCreate: () => + waitForDatabaseQuiet( + session.msgDbPath, + () => ctx.account === session && !controller.signal.aborted, + ), + }), + controller.signal, + ); + if (ctx.account !== session) return; + logger.info('annual report indexes ready', { + event: 'annual-index-ready', + accountUin: session.context.uin, + indexes: installed.map((index) => index.name), + }); + // 读侧复用账号连接;写侧 QqDb.write 已在每条 DDL 结束后释放锁。 + })() + .catch((error) => { + if (controller.signal.aborted) return; + logger.warn('failed to build annual report indexes (report still works, just slower)', { + event: 'annual-index-failed', accountUin: session.context.uin, - indexes: installed.map((index) => index.name), + ...logErrorContext(error), }); - } finally { - db.close(); - } - })().catch((error) => { - logger.warn('failed to build annual report indexes (report still works, just slower)', { - event: 'annual-index-failed', - accountUin: session.context.uin, - ...logErrorContext(error), + }) + .finally(() => { + if (annualIndexController === controller) annualIndexController = null; }); - }); }, ANNUAL_INDEX_WARMUP_DELAY_MS); } @@ -841,6 +857,8 @@ export interface AccountServices { interaction: InteractionService; /** 会话治理:撤回 + 群管理(改群名片 / 踢人 / 禁言 / 设撤管理员,需在线 QQ 发包)。 */ groupModeration: GroupModerationService; + /** 位置共享(QQLBSShareSvc 发起 / 上报 / 查询,需在线 QQ 发包)。 */ + locationShare: LocationShareService; accountWrite: AccountWriteService; /** 闪传浏览 / 下载(匿名 HTTP2RPC,不需 QQ 在线)。 */ flashTransferFiles: FlashTransferFilesService; @@ -957,6 +975,8 @@ export interface AppContext { * takes effect immediately. No-op when no account is open. */ applyRealtime(enabled: boolean): void; + /** 自动策略失效时取消旧采集,保留仍有效的本来源手动请求。 */ + applyFullModePolicy(): void; /** * Apply the SSE 消息推送 config to the open account (start / stop / retarget @@ -978,13 +998,8 @@ export interface AppContext { */ applyMcp(config: McpServerConfig): Promise; /** - * Apply the WeQ 助手 config: when enabled, publish the tweet pages / covers - * into the daemon docroot, make sure the weq-daemon companion process is up - * (only replaced when the on-disk binary's version changes), start its static - * HTTP server and — with an account open — sync the ARK cards into the QQ db. - * When disabled, stop the daemon's HTTP only (the daemon process itself is - * left running); the fabricated conversation rows are removed best-effort. - * Returns the port the daemon actually bound to (or 0). + * 启用时发布推文并在当前宿主内提供资源;有账号时同步既有 QQ 卡片。 + * 停用时关闭本轮资源服务,退出后不保留任何独立后台。返回实际端口或 0。 */ applyWeqAssistant(config: WeqAssistantConfig): Promise; /** @@ -993,6 +1008,7 @@ export interface AppContext { * no account is open / QQ is offline / harvest failed. */ refreshRkeysNow(): Promise; + onlineCredentialsPending(): boolean; /** * Transcribe a SILK file on disk with the globally selected model. Account * independent (the model is a global setting), so callers that already have a @@ -1027,6 +1043,7 @@ function unavailableContext(nativeError: NativeInitError | null, reason: string) applyRealtime(): void { /* no account to watch */ }, + applyFullModePolicy(): void {}, applySsePush(): Promise { /* no account — nothing to push */ return Promise.resolve(); @@ -1046,6 +1063,9 @@ function unavailableContext(nativeError: NativeInitError | null, reason: string) refreshRkeysNow(): Promise { return Promise.resolve(false); }, + onlineCredentialsPending(): boolean { + return false; + }, transcribeSilk(): Promise { return Promise.resolve({ ok: false, error: reason }); }, @@ -1151,7 +1171,7 @@ export function initAppContext(fileRoot?: string): AppContext { const attachHook: AttachHook = process.platform === 'linux' || process.platform === 'darwin' ? createLinuxAttachHook(platform.native.ntHelper, userConfig) - : createDirectAttachHook(platform.native.ntHelper); + : createDirectAttachHook(platform.native.ntHelper, tryLongTermAttach); const linkPreview = new LinkPreviewService(userConfig); const apiUsage = new TokenUsageStore(join(userConfig.cacheDir('api_usage'), 'global.json')); @@ -1239,9 +1259,11 @@ export function initAppContext(fileRoot?: string): AppContext { accountCtx: AccountContext, metadata: AccountConfigMetadata = {}, ): Promise { + clearFullMode(this); const base = this.platform; if (!base) throw new Error('原生组件尚未就绪'); const platform = base; + cancelAnnualReportWarmup(); invalidateAccountTools(this.account); cancelAssistantRuns(); this.scheduler?.stop(); @@ -1262,7 +1284,9 @@ export function initAppContext(fileRoot?: string): AppContext { this.services?.roamingBackfill.dispose(); void this.services?.favoriteEmojiImport.dispose(); this.services?.mail.dispose(); - this.services?.unreadInfo.dispose(); + this.services?.annualReport.dispose(); + await this.services?.unreadInfo.dispose(); + await waitForUnreadWrites(); this.account?.dispose(); dbWatchHandle?.unmount(); dbWatchHandle = null; @@ -1313,6 +1337,7 @@ export function initAppContext(fileRoot?: string): AppContext { // Keyed by the same (uin, dataDir) id the account record uses. const exportConfigId = accountConfigId(session.context.uin, metadata.dataDir); const resolveOnlinePid = (): number => { + if (!fullModeAllowed(this, session)) throw new Error('本次运行尚未启用完整功能。'); const record = accountConfig.getRecord(); if (!record?.qqOnline || !record.qqPid) { throw new Error('QQ account is not online.'); @@ -1328,6 +1353,7 @@ export function initAppContext(fileRoot?: string): AppContext { userConfig.cacheDir('media'), // 本机 rkey 不可用(无在线 QQ / 已过期)时回退到外部 rkey 服务器。 bootstrap.externalRkey, + () => fullModeAllowed(this, session), ); // 识别结果缓存(内容寻址,`<缓存根>/ocr/`):灯箱 / MCP / 导出共用同一份实例。 // 刻意不进「清理缓存」白名单 —— 过期图片的结果清掉就再也拿不回来了(见 @@ -1408,8 +1434,11 @@ export function initAppContext(fileRoot?: string): AppContext { const agentlabRoot = userConfig.cacheDir(join('agentlab', exportConfigId)); const tokenUsage = new TokenUsageStore(join(agentlabRoot, 'usage.json')); const conversations = new ConversationStore(join(agentlabRoot, 'conversations.json')); - const resolveAgentEndpoint = (ref: import('@weq/agentlab').AgentLabModelRef) => ({ - ...bootstrap.agentLabConfig.resolveEndpoint(ref), + const resolveAgentEndpoint = ( + ref: import('@weq/agentlab').AgentLabModelSelection, + capability?: import('@weq/agentlab').AgentLabModelCapability, + ) => ({ + ...bootstrap.agentLabConfig.resolveEndpoint(ref, capability), onUsage: (u: import('@weq/agentlab').AgentLabUsage) => tokenUsage.record({ ...u, @@ -1453,7 +1482,7 @@ export function initAppContext(fileRoot?: string): AppContext { platform.native.ntHelper, session, () => { - if (userConfig.getSettings().autoAttachQq === false) + if (!fullModeAllowed(this, session)) throw new Error('完全离线模式已开启,已停止漫游请求。'); return resolveOnlinePid(); }, @@ -1496,7 +1525,7 @@ export function initAppContext(fileRoot?: string): AppContext { if ( this.account !== session || this.accountIsStatic || - userConfig.getSettings().autoAttachQq === false + !fullModeAllowed(this, session) ) return null; const record = accountConfig.getRecord(); @@ -1529,7 +1558,7 @@ export function initAppContext(fileRoot?: string): AppContext { resolveDressNames: createDressNameResolver(dressInstall), qzone: createReportQzoneCapability(webQuery, session.context.uin, () => { const record = accountConfig.getRecord(); - return Boolean(record?.qqOnline && record.qqPid); + return fullModeAllowed(this, session) && Boolean(record?.qqOnline && record.qqPid); }), resolveEmojiNames: async (ids) => { const entries = await emojiService.listSystemFaces(); @@ -1607,7 +1636,7 @@ export function initAppContext(fileRoot?: string): AppContext { conversations, { // 内置工具 + 用户接入的外部 MCP 工具合并;外部列举是惰性异步的。 - specs: async () => [...aiToolSpecs(), ...(await getExternalMcpHub().specs())], + specs: assistantToolSpecs, // 内置 / 外部工具的路由统一在 runAssistantTool 里(run_js 沙箱的 callTool 也走它)。 run: bindAccountTools(session, () => this.account, runAssistantTool), // 配置变更/启动时把外部 MCP 配置同步给 Hub(连接惰性建立)。 @@ -1796,21 +1825,19 @@ export function initAppContext(fileRoot?: string): AppContext { // 收藏导出:翻页拉本地收藏并拍平为可序列化行(复用 IPC 的 wire 投影)。 collection: { listCollections: async (limit, offset) => { - const page = - userConfig.getSettings().autoAttachQq === false - ? await collectionSvc.listCollectionsFromDb(limit, offset) - : await collectionSvc.listCollections(limit, offset); + const page = !fullModeAllowed(this, session) + ? await collectionSvc.listCollectionsFromDb(limit, offset) + : await collectionSvc.listCollections(limit, offset); return page.items.map(collectionItemToWire); }, syncHistory: (signal) => - userConfig.getSettings().autoAttachQq === false + !fullModeAllowed(this, session) ? Promise.reject(new Error('完全离线模式,不同步云端收藏历史。')) : collectionSvc.syncNetworkHistory(signal), canFetchDetails: () => - userConfig.getSettings().autoAttachQq !== false && - accountConfig.getRecord()?.qqOnline === true, + fullModeAllowed(this, session) && accountConfig.getRecord()?.qqOnline === true, fetchDetails: async (cids, signal) => { - if (userConfig.getSettings().autoAttachQq === false) + if (!fullModeAllowed(this, session)) throw new Error('完全离线模式,不请求云端收藏详情。'); const details = await collectionSvc.fetchDetails(cids, { signal }); return details.map((detail) => collectionDetailToWire(detail.cid, detail.content)); @@ -1819,10 +1846,7 @@ export function initAppContext(fileRoot?: string): AppContext { writeHistorySnapshot: (path) => collectionSvc.writeCollectionHistorySnapshot(path), writeDetailSnapshot: (path) => collectionSvc.writeCollectionDetailSnapshot(path), downloadOriginalFiles: (cid, signal) => { - if ( - userConfig.getSettings().autoAttachQq === false || - accountConfig.getRecord()?.qqOnline !== true - ) + if (!fullModeAllowed(this, session) || accountConfig.getRecord()?.qqOnline !== true) throw new Error('离线时不请求收藏附件。'); return collectionSvc.downloadExportMedia(cid, signal); }, @@ -1879,6 +1903,7 @@ export function initAppContext(fileRoot?: string): AppContext { { resolveEndpoint: resolveAgentEndpoint, localEngine: getActiveIntentSenseLocalEngine(), + legacyLocalModelId: userConfig.getLocalModelId('intent'), gapHistory, }, ), @@ -1898,10 +1923,13 @@ export function initAppContext(fileRoot?: string): AppContext { String(session.context.uin), () => { const record = accountConfig.getRecord(); - return record?.qqOnline ? (record.qqPid ?? null) : null; + return fullModeAllowed(this, session) && record?.qqOnline + ? (record.qqPid ?? null) + : null; }, { notificationsEnabled: accountConfig.getRecord()?.mailNotifications ?? false, + allowOnline: () => fullModeAllowed(this, session), onNotice: (notice) => mailNotifier?.(String(session.context.uin), notice), }, ), @@ -1939,9 +1967,12 @@ export function initAppContext(fileRoot?: string): AppContext { } satisfies RoamingCacheChange); }, () => accountConfig.getRecord()?.homeDress?.fontId, + () => userConfig.getSettings().sendWithQqFont, + () => accountConfig.getRecord()?.homeDress?.bubbleId, ), interaction: new InteractionService(platform.native.ntHelper, session, resolveOnlinePid), groupModeration: new GroupModerationService(platform.native.ntHelper, resolveOnlinePid), + locationShare: new LocationShareService(platform.native.ntHelper, resolveOnlinePid), accountWrite: new AccountWriteService( platform.native.ntHelper, { @@ -1979,7 +2010,7 @@ export function initAppContext(fileRoot?: string): AppContext { taskManager: exportManager, isOnline: () => { const r = accountConfig.getRecord(); - return Boolean(r?.qqOnline && r?.qqPid); + return fullModeAllowed(this, session) && Boolean(r?.qqOnline && r?.qqPid); }, }, ); @@ -1993,18 +2024,15 @@ export function initAppContext(fileRoot?: string): AppContext { showUnread: accountConfig.getRecord()?.mailShowUnread ?? true, }); - // Start the background login/pid monitor for this account. Injection + - // harvesting (rkey / clientkey / 装扮快照) inside it is gated live by the - // 自动读取 QQ 内存 master switch (完全离线模式), so toggling that setting takes - // effect on the next poll without a re-open. + // 后台登录/pid 监视器实时检查本来源有效运行态,包含自动策略和显式手动请求; + // 凭据采集在下次轮询生效,不用重新打开账号,也不能复用已失活的会话。 accountMonitor = new AccountMonitorService( session, platform, accountConfig, - () => userConfig.getSettings().autoAttachQq, + () => fullModeAllowed(this, session), bootstrap.attachHook, - // 把手机 QQ 正在用的气泡/字体装上并切过去。monitor 内部只在本次会话第一次 - // 抓到快照时调,且只在用户从没自己选过时才动手(见 syncFromQq)。 + // 首次抓到快照时准备 QQ 同款装扮资源,只加入已装,不自动使用。 async (dress) => { await dressInstall.syncFromQq(dress); // 同步比首屏那次 getState 晚几秒,不推前端会一直显示旧清单。 @@ -2029,6 +2057,7 @@ export function initAppContext(fileRoot?: string): AppContext { return () => accountEventBus.off('onlineCredentialsReady', handler); }, () => accountEventBus.emit('vipChanged', { at: Date.now() }), + () => fullModeAllowed(this, session), ); vipBadge.start(); @@ -2125,7 +2154,9 @@ export function initAppContext(fileRoot?: string): AppContext { ): Promise { const backup = await resolveBackupLocation(dirPath); if (!backup) throw new Error('未找到 QQ 账号数据库目录'); + clearFullMode(this); dirPath = backup.dbDir; + cancelAnnualReportWarmup(); invalidateAccountTools(this.account); cancelAssistantRuns(); logger.info('opening static account session', { @@ -2144,7 +2175,9 @@ export function initAppContext(fileRoot?: string): AppContext { this.services?.roamingBackfill.dispose(); void this.services?.favoriteEmojiImport.dispose(); this.services?.mail.dispose(); - this.services?.unreadInfo.dispose(); + this.services?.annualReport.dispose(); + await this.services?.unreadInfo.dispose(); + await waitForUnreadWrites(); this.account?.dispose(); dbWatchHandle?.unmount(); dbWatchHandle = null; @@ -2218,6 +2251,7 @@ export function initAppContext(fileRoot?: string): AppContext { // elsewhere. The monitor (started at the end) records its pid, so this // resolves lazily per call — going online mid-session needs no reopen. const livePid = (): number => { + if (!fullModeAllowed(this, session)) throw new Error('本次运行尚未启用完整功能。'); const pid = accountConfig.getRecord()?.qqPid; if (!pid) { throw new Error('QQ 未在线(静态账号需登录同一账号的 QQ 客户端后才能使用在线功能)。'); @@ -2229,6 +2263,7 @@ export function initAppContext(fileRoot?: string): AppContext { accountConfig, userConfig.cacheDir('media'), bootstrap.externalRkey, + () => fullModeAllowed(this, session), ); /** 识别结果缓存(同上:与灯箱 / MCP 共用一份,见 `common/ocr_cache.ts`)。 */ const ocrCache = ocrCacheFor(userConfig.cacheDir('ocr')); @@ -2281,8 +2316,11 @@ export function initAppContext(fileRoot?: string): AppContext { const agentlabRoot = userConfig.cacheDir(join('agentlab', exportConfigId)); const tokenUsage = new TokenUsageStore(join(agentlabRoot, 'usage.json')); const conversations = new ConversationStore(join(agentlabRoot, 'conversations.json')); - const resolveAgentEndpoint = (ref: import('@weq/agentlab').AgentLabModelRef) => ({ - ...bootstrap.agentLabConfig.resolveEndpoint(ref), + const resolveAgentEndpoint = ( + ref: import('@weq/agentlab').AgentLabModelSelection, + capability?: import('@weq/agentlab').AgentLabModelCapability, + ) => ({ + ...bootstrap.agentLabConfig.resolveEndpoint(ref, capability), onUsage: (u: import('@weq/agentlab').AgentLabUsage) => tokenUsage.record({ ...u, @@ -2321,7 +2359,7 @@ export function initAppContext(fileRoot?: string): AppContext { platform.native.ntHelper, session, () => { - if (userConfig.getSettings().autoAttachQq === false) + if (!fullModeAllowed(this, session)) throw new Error('完全离线模式已开启,已停止漫游请求。'); return livePid(); }, @@ -2369,7 +2407,7 @@ export function initAppContext(fileRoot?: string): AppContext { resolveDressNames: createDressNameResolver(dressInstall), qzone: createReportQzoneCapability(webQuery, session.context.uin, () => { const record = accountConfig.getRecord(); - return Boolean(record?.qqOnline && record.qqPid); + return fullModeAllowed(this, session) && Boolean(record?.qqOnline && record.qqPid); }), resolveEmojiNames: async (ids) => { const entries = await emojiService.listSystemFaces(); @@ -2446,7 +2484,7 @@ export function initAppContext(fileRoot?: string): AppContext { conversations, { // 内置工具 + 用户接入的外部 MCP 工具合并;外部列举是惰性异步的。 - specs: async () => [...aiToolSpecs(), ...(await getExternalMcpHub().specs())], + specs: assistantToolSpecs, // 内置 / 外部工具的路由统一在 runAssistantTool 里(run_js 沙箱的 callTool 也走它)。 run: bindAccountTools(session, () => this.account, runAssistantTool), // 配置变更/启动时把外部 MCP 配置同步给 Hub(连接惰性建立)。 @@ -2555,21 +2593,19 @@ export function initAppContext(fileRoot?: string): AppContext { // 收藏导出:静态账号同样有本地收藏库,可离线导出。 collection: { listCollections: async (limit, offset) => { - const page = - userConfig.getSettings().autoAttachQq === false - ? await collectionSvc.listCollectionsFromDb(limit, offset) - : await collectionSvc.listCollections(limit, offset); + const page = !fullModeAllowed(this, session) + ? await collectionSvc.listCollectionsFromDb(limit, offset) + : await collectionSvc.listCollections(limit, offset); return page.items.map(collectionItemToWire); }, syncHistory: (signal) => - userConfig.getSettings().autoAttachQq === false + !fullModeAllowed(this, session) ? Promise.reject(new Error('完全离线模式,不同步云端收藏历史。')) : collectionSvc.syncNetworkHistory(signal), canFetchDetails: () => - userConfig.getSettings().autoAttachQq !== false && - accountConfig.getRecord()?.qqOnline === true, + fullModeAllowed(this, session) && accountConfig.getRecord()?.qqOnline === true, fetchDetails: async (cids, signal) => { - if (userConfig.getSettings().autoAttachQq === false) + if (!fullModeAllowed(this, session)) throw new Error('完全离线模式,不请求云端收藏详情。'); const details = await collectionSvc.fetchDetails(cids, { signal }); return details.map((detail) => collectionDetailToWire(detail.cid, detail.content)); @@ -2578,10 +2614,7 @@ export function initAppContext(fileRoot?: string): AppContext { writeHistorySnapshot: (path) => collectionSvc.writeCollectionHistorySnapshot(path), writeDetailSnapshot: (path) => collectionSvc.writeCollectionDetailSnapshot(path), downloadOriginalFiles: (cid, signal) => { - if ( - userConfig.getSettings().autoAttachQq === false || - accountConfig.getRecord()?.qqOnline !== true - ) + if (!fullModeAllowed(this, session) || accountConfig.getRecord()?.qqOnline !== true) throw new Error('离线时不请求收藏附件。'); return collectionSvc.downloadExportMedia(cid, signal); }, @@ -2622,6 +2655,7 @@ export function initAppContext(fileRoot?: string): AppContext { { resolveEndpoint: resolveAgentEndpoint, localEngine: getActiveIntentSenseLocalEngine(), + legacyLocalModelId: userConfig.getLocalModelId('intent'), gapHistory, }, ), @@ -2643,10 +2677,13 @@ export function initAppContext(fileRoot?: string): AppContext { String(session.context.uin), () => { const record = accountConfig.getRecord(); - return record?.qqOnline ? (record.qqPid ?? null) : null; + return fullModeAllowed(this, session) && record?.qqOnline + ? (record.qqPid ?? null) + : null; }, { notificationsEnabled: accountConfig.getRecord()?.mailNotifications ?? false, + allowOnline: () => fullModeAllowed(this, session), onNotice: (notice) => mailNotifier?.(String(session.context.uin), notice), }, ), @@ -2675,9 +2712,12 @@ export function initAppContext(fileRoot?: string): AppContext { } satisfies RoamingCacheChange); }, () => accountConfig.getRecord()?.homeDress?.fontId, + () => userConfig.getSettings().sendWithQqFont, + () => accountConfig.getRecord()?.homeDress?.bubbleId, ), interaction: new InteractionService(platform.native.ntHelper, session, livePid), groupModeration: new GroupModerationService(platform.native.ntHelper, livePid), + locationShare: new LocationShareService(platform.native.ntHelper, livePid), accountWrite: new AccountWriteService( platform.native.ntHelper, { @@ -2745,7 +2785,7 @@ export function initAppContext(fileRoot?: string): AppContext { session, platform, accountConfig, - () => userConfig.getSettings().autoAttachQq, + () => fullModeAllowed(this, session), bootstrap.attachHook, undefined, () => { @@ -2766,6 +2806,7 @@ export function initAppContext(fileRoot?: string): AppContext { return () => accountEventBus.off('onlineCredentialsReady', handler); }, () => accountEventBus.emit('vipChanged', { at: Date.now() }), + () => fullModeAllowed(this, session), ); vipBadge.start(); @@ -2799,6 +2840,7 @@ export function initAppContext(fileRoot?: string): AppContext { // Still no anti-recall triggers, no health check and no scheduler. }, clearAccount(): void { + clearFullMode(this); invalidateAccountTools(this.account); cancelAssistantRuns(); logger.info('clearing account session', { @@ -2821,10 +2863,7 @@ export function initAppContext(fileRoot?: string): AppContext { // 取消还没触发的索引预热:索引属于刚关掉的那个会话,别在关账号后 // 还去写它的库(此刻 this.account 已改成 null,定时器里的守卫也会兜住, // 这里显式清掉只是不留悬挂的定时器)。 - if (annualIndexWarmup) { - clearTimeout(annualIndexWarmup); - annualIndexWarmup = null; - } + cancelAnnualReportWarmup(); unmountDbWatch(); ssePush?.stop(); ssePush = null; @@ -2833,7 +2872,10 @@ export function initAppContext(fileRoot?: string): AppContext { this.services?.roamingBackfill.dispose(); void this.services?.favoriteEmojiImport.dispose(); this.services?.mail.dispose(); - this.services?.unreadInfo.dispose(); + this.services?.annualReport.dispose(); + void this.services?.unreadInfo.dispose().catch((error) => { + console.error('[unread] 关闭账号时保存本地阅读状态失败', error); + }); this.services?.contentSearch.close(); this.services?.guildDirect.cancelSearch(); this.account?.dispose(); @@ -2926,13 +2968,9 @@ export function initAppContext(fileRoot?: string): AppContext { port: config.port, }); - // 关闭:只关守护进程上的 HTTP(守护进程本体绝不动 —— 它的生命周期属于 - // 开机自启 / 用户)。会话行的删除仍需要开着账号,best-effort。 + // 停用本进程的资源服务;会话行的删除仍需要开着账号,best-effort。 if (!config.enabled) { - const stopped = await daemonHttpStop(); - if (!stopped) { - logger.warn('failed to stop weq daemon http', { event: 'weq-http-stop-failed' }); - } + await assistantResourceServer.stop(); if (this.account && this.platform) { // 静态账号禁止往原生 nt_data 写助手头像(见下方开启路径的说明)。 const svc = new WeqAssistantService( @@ -2956,26 +2994,23 @@ export function initAppContext(fileRoot?: string): AppContext { // 1) 发布静态产物到 docroot(推文页 / 封面 / 头像;主题此刻烘焙进文件)。 // docroot 放在 weq-assistant 缓存目录下:清理缓存时自然一并回收。 const docroot = join(userConfig.cacheDir('weq-assistant'), 'docroot'); - try { - await publishWeqAssistantDocroot({ docroot }); - } catch (error) { - logger.error('failed to publish weq assistant docroot', { - event: 'weq-publish-failed', - ...logErrorContext(error), - }); - } - - // 2) 确保守护进程在(不在则 detached 拉起;只有磁盘上的二进制换了版本才替换); - // 3) 开 HTTP,端口被占自动向后回落试探。HTTP 与账号无关:没开账号也能开。 - const ready = await ensureDaemonRunning(); - if (!ready) { - throw new Error('weq-daemon 未能启动或控制管道连接超时'); - } - const start = await startDaemonHttp(config.port, docroot); - if (!start.ok) { - throw new Error(start.message ?? 'weq-daemon http_start failed'); - } - const boundPort = start.port ?? config.port; + const boundPort = await assistantResourceServer.publishAndStart( + config.port, + docroot, + async () => { + try { + await publishWeqAssistantDocroot({ docroot }); + } catch (error) { + logger.error('failed to publish weq assistant docroot', { + event: 'weq-publish-failed', + ...logErrorContext(error), + }); + } + }, + ); + // 发布期间停用或退出时,旧请求不能重新开启服务或写回端口。 + if (boundPort === null) return 0; + if (boundPort !== config.port) userConfig.setSettings({ weqAssistant: { port: boundPort } }); // 4) 注入/刷新 QQ 库里的卡片:ensureMapping + 逐条补卡 + rewriteArkPort // (存量卡片端口刷成当前实际端口)+ 会话列表预览。需要开着账号, @@ -2994,9 +3029,6 @@ export function initAppContext(fileRoot?: string): AppContext { const tweets = ensureDefaultTweets(storePath); const logo = resolveResource('brand', 'logo.png') ?? undefined; await svc.syncTweets(boundPort, tweets, logo); - if (boundPort !== config.port) { - userConfig.setSettings({ weqAssistant: { port: boundPort } }); - } } catch (error) { logger.error('failed to sync weq assistant tweets', { event: 'weq-ensure-failed', @@ -3031,6 +3063,17 @@ export function initAppContext(fileRoot?: string): AppContext { }); return accountMonitor?.harvestRkeysNow() ?? Promise.resolve(false); }, + onlineCredentialsPending(): boolean { + return accountMonitor?.credentialsPending ?? false; + }, + applyFullModePolicy(): void { + if (fullModeAllowed(this)) return; + this.services?.unreadInfo.cancelReports(); + this.services?.mail.suspendOnline(); + // stop 的 generation/AbortSignal 隔离尚在等待授权的旧读取;监视在线状态继续运行。 + accountMonitor?.stop(); + accountMonitor?.start(); + }, }; cached = ctx; @@ -3049,14 +3092,14 @@ export function getAppContext(): AppContext { } /** - * 完全离线模式提示。所有会触发 hook 注入 / 读取在线凭证的功能入口都应先调用 - * {@link requireAttachEnabled},避免在「自动读取 QQ 内存」关闭时仍悄悄注入。 + * 所有会触发 hook 注入 / 读取在线凭证的功能入口都应先调用 + * {@link requireAttachEnabled},检查本来源有效运行态,不用自动偏好推测授权。 * 唯一豁免:登录时的数据库密钥提取(bootstrap 登录流程)——那是打开账号的前提。 */ export function requireAttachEnabled(): void { const ctx = getAppContext(); - if (ctx.bootstrap?.userConfig.getSettings().autoAttachQq === false) { - throw new Error('已开启完全离线模式(自动读取 QQ 内存 已关闭),该功能需要在线 QQ 实例。'); + if (!fullModeAllowed(ctx)) { + throw new Error('本次运行尚未启用完整功能,请在受限模式详情中启用后重试。'); } } diff --git a/apps/desktop/src/main/context/full_mode.ts b/apps/desktop/src/main/context/full_mode.ts new file mode 100644 index 000000000..3d19c5342 --- /dev/null +++ b/apps/desktop/src/main/context/full_mode.ts @@ -0,0 +1,27 @@ +interface FullModeHost { + account: object | null; + bootstrap: { userConfig: { getSettings(): { autoAttachQq: boolean } } } | null; +} + +/** 宿主与完整会话实例双重绑定;不进入配置、备份或 renderer 状态。 */ +const manualRequests = new WeakMap(); +const retiredSources = new WeakSet(); + +export function fullModeAllowed(host: FullModeHost, expected = host.account): boolean { + if (host.account !== expected || (expected && retiredSources.has(expected))) return false; + return Boolean( + host.bootstrap?.userConfig.getSettings().autoAttachQq || + (expected && manualRequests.get(host) === expected), + ); +} + +export function requestFullMode(host: FullModeHost, expected: object): void { + if (host.account !== expected || retiredSources.has(expected)) + throw new Error('账号来源已切换或关闭,请重新启用完整功能'); + manualRequests.set(host, expected); +} + +export function clearFullMode(host: FullModeHost): void { + if (host.account) retiredSources.add(host.account); + manualRequests.delete(host); +} diff --git a/apps/desktop/src/main/daemon/health.ts b/apps/desktop/src/main/daemon/health.ts deleted file mode 100644 index d89b03bc9..000000000 --- a/apps/desktop/src/main/daemon/health.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * 守护进程健康 / release 监控 / 自启动 的聚合查询(bootstrap router 用)。 - * - * 「健康性」= 一次管道往返能拿到的全部事实: - * - 进程在不在(ping 成功与否)+ 守护进程版本; - * - HTTP 服务状态(运行 / 端口 / docroot); - * - release 轮询状态(watching / 最近错误 / pending); - * - 自启动状态(WeQ 的开机拉起意图 + 守护进程自身原生注册是否在位)。 - * - * 全部只读、无副作用:设置页可以随便轮询,不需要任何「开始/停止」。 - */ - -import { - pingDaemon, - daemonHttpStatus, - daemonReleaseWatchStatus, - daemonAutostartStatus, - DAEMON_PIPE_NAME, - getHost, -} from '@weq/service'; - -/** {@link getDaemonHealth} 的返回形状。 */ -export interface DaemonHealth { - /** 守护进程可探活(管道连通)。 */ - alive: boolean; - /** 守护进程自身版本(alive 时有效)。 */ - version: string | null; - /** 当前宿主是否允许设置开机自启(浏览器版 / 开发模式 = false)。 */ - autostartSupported: boolean; - http: { - running: boolean; - port: number | null; - docroot: string | null; - } | null; - release: { - watching: boolean; - repo: string | null; - intervalSecs: number | null; - currentVersion: string | null; - latestSeen: string | null; - /** 未确认的新版本(GUI 需要提醒的)。 */ - pending: string | null; - /** 最近一次 GitHub 轮询错误(null = 正常)。 */ - lastError: string | null; - } | null; - autostart: { - /** WeQ 设置的意图:开机后是否由守护进程拉起 GUI(存在守护进程记忆里)。 */ - enabled: boolean; - /** **守护进程自身**的原生自启注册是否在位(全机唯一那一份)。 */ - registered: boolean; - } | null; -} - -/** - * 一次聚合的守护进程健康快照。守护进程不在时 alive=false,其余子项为 - * null(设置页显示「守护进程未运行」,并给出拉起指引)。 - */ -export async function getDaemonHealth(): Promise { - const autostartSupported = getHost().canAutostart; - const pong = await pingDaemon(DAEMON_PIPE_NAME); - if (!pong) { - return { - alive: false, - version: null, - autostartSupported, - http: null, - release: null, - autostart: null, - }; - } - const [http, release, autostart] = await Promise.all([ - daemonHttpStatus(DAEMON_PIPE_NAME), - daemonReleaseWatchStatus(DAEMON_PIPE_NAME), - daemonAutostartStatus(DAEMON_PIPE_NAME), - ]); - return { - alive: true, - version: pong.version, - autostartSupported, - http: http ? { running: http.running, port: http.port, docroot: http.docroot } : null, - release: release - ? { - watching: release.watching, - repo: release.repo, - intervalSecs: release.interval_secs, - currentVersion: release.current_version, - latestSeen: release.latest_seen, - pending: release.pending, - lastError: release.last_error, - } - : null, - autostart: autostart ?? null, - }; -} diff --git a/apps/desktop/src/main/daemon/release_monitor.ts b/apps/desktop/src/main/daemon/release_monitor.ts deleted file mode 100644 index 2d783b0ba..000000000 --- a/apps/desktop/src/main/daemon/release_monitor.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * GitHub release 监控的 GUI 侧编排(Electron 主进程)。 - * - * 分工(对应守护进程 release.rs 的约定): - * - 守护进程:真正轮询 GitHub(Rust 实现),发现新版本置 `pending`; - * - 本模块:定期读 `release_watch_status`,看到 `pending` 就 - * 1) 弹**系统通知**(Electron Notification,点击跳 GitHub release 页); - * 2) `release_ack` 告诉守护进程「已处理」,下一轮不再重复提醒。 - * - * 这里只负责「提醒更新」;把新版本做成 WeQ 助手推文的逻辑由打包版应用内更新 - * 检查统一负责(update/updater.ts → weq_assistant/update_tweet.ts,写入 - * `/p/update` + 本地推文库 + QQ),本模块不再重复写 docroot / 库。 - * - * ack 即「用户已被提醒过」。守护进程重启后 pending 仍在,WeQ 重启也能补提醒一次。 - */ - -import { app, Notification, shell } from 'electron'; -import { - daemonReleaseWatchStatus, - daemonReleaseAck, - getLogger, - logErrorContext, - type DaemonReleaseWatchInfo, -} from '@weq/service'; -import { getReleaseChangelogEntry } from '../weq_assistant/changelog'; -import { resolveResource } from '../resource'; - -const logger = getLogger().child({ scope: 'daemon-release-monitor' }); - -/** 设置页轮询间隔(守护进程自己在按小时轮 GitHub,这里只管「提醒」)。 */ -const POLL_INTERVAL_MS = 30_000; - -let timer: NodeJS.Timeout | null = null; -/** 通知里点「查看」要跳的 URL(按版本缓存,notification 回闭包取用)。 */ -let lastReleaseUrl: string | null = null; - -/** 当前守护进程的 release 状态快照(设置页查询用)。 */ -export async function currentReleaseStatus(): Promise { - return daemonReleaseWatchStatus(); -} - -/** - * 确保 release 提醒循环在跑(幂等)。随 Electron 启动挂着;守护进程不在时 - * 循环空转(每轮查询返回 null,下轮再试),watching=false 也不产生提醒。 - */ -export function startReleaseMonitor(): void { - if (timer) return; - timer = setInterval(() => { - void tick().catch((error) => { - logger.warn('release monitor tick failed', { - event: 'release-monitor-tick-failed', - ...logErrorContext(error), - }); - }); - }, POLL_INTERVAL_MS); - logger.info('release monitor started', { event: 'release-monitor-start' }); -} - -/** 停止提醒循环(保留给需要停用的调用方;守护进程的 GitHub 轮询独立存在,不受影响)。 */ -export function stopReleaseMonitor(): void { - if (!timer) return; - clearInterval(timer); - timer = null; - logger.info('release monitor stopped', { event: 'release-monitor-stop' }); -} - -/** 一轮:读状态 → 有 pending 就通知 + ack。 */ -async function tick(): Promise { - if (!Notification.isSupported()) return; // 无通知途径:跳过本轮,pending 保留待下次重试 - const status = await daemonReleaseWatchStatus(); - const pending = status?.pending; - if (!pending) return; - - const version = pending.replace(/^v/i, ''); - logger.info('new release detected by daemon', { - event: 'release-detected', - version, - latest: status?.latest_seen ?? null, - }); - - // 1) 系统通知(新 release 的提醒方式)。 - const releaseUrl = `https://github.com/weq-devs/WeQ/releases/tag/v${version}`; - lastReleaseUrl = releaseUrl; - const entry = getReleaseChangelogEntry(version); - const summary = entry?.summary ?? `WeQ ${version} 发布了,点击查看更新内容。`; - showNotification(version, summary, releaseUrl); - - // 2) ack:守护进程清 pending、推进 current_version。 - const after = await daemonReleaseAck(pending); - logger.info('release acknowledged to daemon', { - event: 'release-acked', - version, - pending: after?.pending ?? null, - }); -} - -/** 发系统通知;点击跳 GitHub release 页。 */ -function showNotification(version: string, summary: string, releaseUrl: string): void { - const notification = new Notification({ - title: `WeQ ${version} 发布了`, - body: summary, - icon: resolveResource('brand', 'logo.png') ?? undefined, - }); - notification.on('click', () => { - void shell.openExternal(lastReleaseUrl ?? releaseUrl); - }); - notification.show(); -} - -/** 应用版本(`1.2.3`,不带 v)。 */ -export function currentAppVersion(): string { - return app.getVersion(); -} diff --git a/apps/desktop/src/main/daemon/runtime.ts b/apps/desktop/src/main/daemon/runtime.ts deleted file mode 100644 index b22791c27..000000000 --- a/apps/desktop/src/main/daemon/runtime.ts +++ /dev/null @@ -1,381 +0,0 @@ -/** - * weq-daemon 运行时编排(Electron / Web 共用)。 - * - * 职责边界(刻意收窄): - * - 把随包发布的二进制 **stage 到稳定路径**(数据目录的 `bin/`):只有稳定 - * 路径才能被写进原生自启注册 —— AppImage 每次运行挂到不同临时目录,注册 - * 指向包内路径的话重启即失效。 - * (Windows 上顺手补一个隐藏拉起器 `weq-daemon-launch.exe` —— 计划任务的动作 - * 指向它,直接注册守护进程本体的话登录时会挂一个关不掉的控制台窗口。) - * - 保证守护进程「在,且是磁盘上那个版本」:探活 + 比版本,一致就什么都不做; - * 版本变了才 `stop` 掉旧的、覆盖落位、再拉起新的;不在则 detached 拉起。 - * - 平时【不主动杀掉守护进程】—— 它的生命周期属于系统自启 / 用户,不属于 GUI; - * 设置开关 OFF 只发 `http_stop`(进程继续活着、记忆清空)。 - * - 替调用方把 http_start / http_stop 讲给守护进程(含端口回落试探)。 - * - 二进制路径解析:与 native/ 完全同构的 `daemon/-/` 布局。 - * - * 端口 / docroot 一律由调用方传入,本模块不持有任何业务配置。 - */ - -import { execFile, spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { chmod, copyFile, mkdir, rename, rm } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - -import { - callDaemon, - daemonAutostartSet, - daemonAutostartStatus, - daemonHttpStart, - daemonStop, - getHost, - getLogger, - pingDaemon, - DAEMON_PIPE_NAME, - type DaemonRequest, - type DaemonResponse, -} from '@weq/service'; -import { resolveResource } from '../resource'; - -const here = dirname(fileURLToPath(import.meta.url)); - -/** 唤醒窗口:spawn `serve` 后等管道就绪的最长时间。 */ -const SPAWN_READY_TIMEOUT_MS = 8000; -/** 探活轮询间隔。 */ -const SPAWN_POLL_INTERVAL_MS = 200; -/** - * 重试 spawn 的最小间隔:旧进程刚退出时管道可能还没释放(win32 的 - * first_pipe_instance 会立刻失败),所以唤醒窗口内不是只拉一次。 - */ -const SPAWN_RETRY_INTERVAL_MS = 1500; -/** `stop` 之后等旧进程真正退出的最长时间。 */ -const STOP_WAIT_TIMEOUT_MS = 5000; -/** 读二进制 `--version` 的超时。 */ -const VERSION_PROBE_TIMEOUT_MS = 3000; - -const execFileAsync = promisify(execFile); -const logger = getLogger().child({ scope: 'daemon-runtime' }); - -/** 从 `weq-daemon 1.0.0` 里取出 `1.0.0`;解析不出返回 null。 */ -export function parseDaemonVersion(output: string): string | null { - const match = /(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/.exec(output); - return match?.[1] ?? null; -} - -/** - * 磁盘上那个二进制的版本号(` --version`)。读不到(权限 / 损坏 / 超时)返回 - * null —— 调用方据此跳过版本判定,只保证「在跑」。 - */ -async function readDaemonBinaryVersion(exe: string): Promise { - try { - const { stdout } = await execFileAsync(exe, ['--version'], { - timeout: VERSION_PROBE_TIMEOUT_MS, - windowsHide: true, - }); - return parseDaemonVersion(String(stdout)); - } catch (error) { - logger.warn('failed to read weq-daemon version from the binary', { - event: 'daemon-version-probe-failed', - errorMessage: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** 发 `stop` 并等管道消失(旧进程一退出就算成功)。 */ -async function stopDaemonAndWait(pipeName: string): Promise { - if (!(await daemonStop(pipeName))) return; // 本来就不在 - const deadline = Date.now() + STOP_WAIT_TIMEOUT_MS; - while (Date.now() < deadline) { - if (!(await pingDaemon(pipeName))) return; - await new Promise((resolve) => setTimeout(resolve, SPAWN_POLL_INTERVAL_MS)); - } - logger.warn('weq-daemon did not exit after stop', { event: 'daemon-stop-timeout' }); -} - -/** spawn ` serve`(独立进程:WeQ 退出它照常活着)。 */ -function spawnDaemonServe(exe: string, pipeName: string): void { - // serve 模式:常驻等命令。stdio 全部丢弃 —— 守护进程自己写 stderr 日志; - // 独立进程也拿不到 GUI 的 console。 - // - // 只有「参与系统自启」的宿主(打包版桌面)才让守护进程把自启注册写进系统: - // 开发态(pnpm dev)的二进制是仓库构建产物,浏览器版则是部署方用 systemd / - // 计划任务托管 —— 都不该由我们再注册一份(见 Rust 侧 NO_AUTOSTART_ENV)。 - const env = { ...process.env }; - if (!getHost().canAutostart) env.WEQ_DAEMON_NO_AUTOSTART = '1'; - const child = spawn(exe, ['serve', '--pipe', pipeName], { - detached: true, - stdio: 'ignore', - windowsHide: true, - env, - }); - child.unref(); -} - -/** - * 轮询管道就绪;窗口内按 {@link SPAWN_RETRY_INTERVAL_MS} 补拉,覆盖「旧进程刚 - * 退出、管道还没释放」的那一小段窗口。 - */ -async function spawnDaemonAndWait(exe: string, pipeName: string): Promise { - const deadline = Date.now() + SPAWN_READY_TIMEOUT_MS; - let nextSpawnAt = 0; - for (;;) { - if (await pingDaemon(pipeName)) return true; - const now = Date.now(); - if (now >= deadline) return false; - if (now >= nextSpawnAt) { - nextSpawnAt = now + SPAWN_RETRY_INTERVAL_MS; - spawnDaemonServe(exe, pipeName); - } - await new Promise((resolve) => setTimeout(resolve, SPAWN_POLL_INTERVAL_MS)); - } -} - -export function resolveDaemonBinary(): string | null { - const exe = process.platform === 'win32' ? 'weq-daemon.exe' : 'weq-daemon'; - const platformArch = `${process.platform}-${process.arch}`; - - const override = process.env.WEQ_DAEMON_DIR; - if (override) { - const candidate = join(override, exe); - if (existsSync(candidate)) return candidate; - } - - const packaged = resolveResource('daemon', platformArch, exe); - if (packaged) return packaged; - - let dir = here; - for (let i = 0; i < 6; i += 1) { - const candidate = join(dir, 'resources', 'daemon', platformArch, exe); - if (existsSync(candidate)) return candidate; - const parent = dirname(dir); - if (parent === dir) break; - dir = parent; - } - return null; -} - -/** 守护进程的状态目录(与 daemon 的 `persist.rs::state_dir` 必须保持一致)。 */ -function daemonStateDir(): string { - if (process.platform === 'win32') { - return join(process.env.LOCALAPPDATA ?? homedir(), 'weq-daemon'); - } - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'weq-daemon'); - } - return join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'weq-daemon'); -} - -/** - * 守护进程的**稳定落位** —— 原生自启注册只能指向这里。 - * - * 包内路径不可靠:AppImage 每次运行挂到 `/tmp/.mount_xxx/…`,`tar.gz` 也可能是 - * 用户随便解压的临时目录。注册指向那些路径的话重启后 unit / task / plist 全部 - * Exec 失败(这正是曾经线上故障的直接原因)。 - */ -export function stagedDaemonPath(): string { - const exe = process.platform === 'win32' ? 'weq-daemon.exe' : 'weq-daemon'; - return join(daemonStateDir(), 'bin', exe); -} - -/** - * 把随包发布的二进制原子地放到目标路径(守护进程本体与 Windows 隐藏拉起器共用)。 - * - * 同目录 `tmp` + `rename` 原子替换;失败不抛 —— 调用方退化成「本次会话直接用 - * 包内二进制」,只记日志(Windows 上目标被占用是唯一的常见失败原因,而调用方 - * 已经先 `stop` 了旧实例)。 - */ -async function stageDaemonBinary(source: string, target: string): Promise { - const tmp = `${target}.tmp`; - try { - await mkdir(dirname(target), { recursive: true }); - await copyFile(source, tmp); - await chmod(tmp, 0o755); - await rename(tmp, target); - logger.info('weq-daemon staged', { event: 'daemon-staged', target }); - return true; - } catch (error) { - await rm(tmp, { force: true }).catch(() => {}); - logger.warn('failed to stage weq-daemon binary', { - event: 'daemon-stage-failed', - target, - errorMessage: error instanceof Error ? error.message : String(error), - }); - return false; - } -} - -/** Windows 隐藏拉起器的文件名(与包内 `resources/daemon//` 同构)。 */ -const WINDOWS_LAUNCHER_EXE = 'weq-daemon-launch.exe'; - -/** - * 把 Windows 隐藏拉起器补到稳定落位(仅 win32)。 - * - * 计划任务的动作指向拉起器而不是守护进程本体:本体是控制台子系统程序,被计划 - * 任务在交互式会话里拉起时 Windows 会分配一个 conhost 窗口,常驻进程就变成开机 - * 挂在桌面、不会自己关闭的黑框(见 `packages/daemon/src/bin/weq-daemon-launch.rs`)。 - * - * 平时只在缺失时拷贝(拉起器只是「换个不创建控制台的方式 spawn 同目录的本体」, - * 旧的那份一样能用);`refresh` 留给版本升级 —— 换了安装包时顺手刷新,免得拉起器 - * 自身的改动永远追不上已落位的那份。包内没有(非 Windows 产物 / 老的 resources) - * 就跳过 —— 守护进程自己会退回注册本体,自启不会因此坏掉。 - */ -async function ensureLauncherStaged(bundledDaemon: string, refresh = false): Promise { - if (process.platform !== 'win32') return; - const source = join(dirname(bundledDaemon), WINDOWS_LAUNCHER_EXE); - if (!existsSync(source)) return; - const target = join(dirname(stagedDaemonPath()), WINDOWS_LAUNCHER_EXE); - if (!refresh && existsSync(target)) return; - await stageDaemonBinary(source, target); -} - -/** - * 确保守护进程「在跑,而且是磁盘上那个版本」: - * - * 1. 包内二进制与稳定落位({@link stagedDaemonPath})比版本,不一致就 `stop` - * 旧实例 → 覆盖落位(版本升级的完整流程); - * 2. 探活 + 读要用的那个二进制的版本(`--version`,两者并行); - * 3. 已在跑且版本一致 → 什么都不做(不重启、不重复拉起); - * 4. 已在跑但版本不同 → `stop` 掉旧的,等管道消失,再拉起新的; - * 5. 不在 → 拉起新的。 - * - * 为什么要 stage:原生自启注册只能指向稳定路径(AppImage 每次运行挂到不同的 - * 临时目录),所以真正被拉起、被注册的永远是稳定落位那份。 - * - * 单例由守护进程自己兜底(重复 `serve` 会立刻退出),这里只负责「版本对齐」。 - * 守护进程的生命周期属系统自启 / 用户:平时从不主动杀它,只有版本变化才替换。 - * 旧进程 `stop` 前会保留状态文件,新进程 `serve` 启动时按记忆自行恢复 HTTP - * (同端口 / 同 docroot),并用新路径幂等重写自启注册。 - * - * 返回 true = 管道可用了(本来就是它 / 刚被拉起 / 刚被换新)。 - */ -export async function ensureDaemonRunning(pipeName: string = DAEMON_PIPE_NAME): Promise { - const bundled = resolveDaemonBinary(); - if (!bundled) { - throw new Error( - '找不到 weq-daemon 二进制(resources/daemon/-/)。请先运行 pnpm build:daemon。', - ); - } - - // WEQ_DAEMON_DIR 是显式指定二进制的逃生口(测试 / 排查):用它时不 stage —— - // 调用方要的就是它自己那个路径,也不想往数据目录写东西。 - const override = Boolean(process.env.WEQ_DAEMON_DIR); - const staged = stagedDaemonPath(); - if (!override) { - // 拉起器要在守护进程 `serve` 自注册之前落位:注册的动作指向它。 - await ensureLauncherStaged(bundled); - const [bundledVersion, stagedVersion] = await Promise.all([ - readDaemonBinaryVersion(bundled), - existsSync(staged) ? readDaemonBinaryVersion(staged) : Promise.resolve(null), - ]); - if (bundledVersion !== null && bundledVersion !== stagedVersion) { - // 版本变了(装了新包 / 重新 build):先停掉旧实例(它可能正占着 staged - // 文件),再覆盖落位。之后 daemon 的 `serve` 会用新路径幂等重写自启注册。 - logger.info('staging weq-daemon', { - event: 'daemon-stage', - bundledVersion, - stagedVersion, - }); - await stopDaemonAndWait(pipeName); - if (!(await stageDaemonBinary(bundled, staged))) { - logger.error('staging failed — falling back to the bundled binary for this session', { - event: 'daemon-stage-unusable', - }); - } - await ensureLauncherStaged(bundled, true); - } - } - - const exe = !override && existsSync(staged) ? staged : bundled; - const [expected, running] = await Promise.all([ - readDaemonBinaryVersion(exe), - pingDaemon(pipeName), - ]); - - if (running) { - // 版本读不出来时保守处理:只保证「在跑」,不做替换。 - if (expected === null || running.version === expected) return true; - logger.info('weq-daemon version changed, replacing the running one', { - event: 'daemon-version-changed', - running: running.version, - expected, - }); - await stopDaemonAndWait(pipeName); - } - - return spawnDaemonAndWait(exe, pipeName); -} - -export interface DaemonHttpStartResult { - ok: boolean; - /** 实际监听端口(ok 时有效)。 */ - port?: number; - /** 失败原因(!ok 时有效)。 */ - message?: string; -} - -/** - * 开启(或按新参数替换)守护进程上的静态 HTTP 服务。若请求端口被占 - * (守护进程 bind 失败),自动向后试探至多 20 个端口 —— 与旧版内置 - * server.ts 的回落行为一致。返回最终绑定的端口。 - */ -export async function startDaemonHttp( - port: number, - docroot: string, - pipeName: string = DAEMON_PIPE_NAME, -): Promise { - const PORT_FALLBACK_ATTEMPTS = 20; - for (let attempt = 0; attempt < PORT_FALLBACK_ATTEMPTS; attempt += 1) { - const candidate = Math.min(port + attempt, 65535); - const result = await daemonHttpStart(candidate, docroot, pipeName); - if (result === null) { - return { ok: false, message: '守护进程不在运行,且无法连接控制管道' }; - } - if (result.ok) return { ok: true, port: result.port }; - // bind 失败才回落;docroot 无效这类错误直接透传,不换端口重试。 - if (!result.message.includes('bind 127.0.0.1')) { - return { ok: false, message: result.message }; - } - } - return { ok: false, message: `端口 ${port}–${port + PORT_FALLBACK_ATTEMPTS - 1} 都被占用` }; -} - -/** - * 把「开机后拉起 WeQ」的意图同步给守护进程(GUI 每次启动做一次)。 - * - * 只落记忆、不注册任何东西 —— 全机唯一的原生自启是守护进程自己那份(它 - * `serve` 时自注册)。这里的作用是**刷新 gui_exe**:记忆里存的是 GUI 二进制路径, - * 换安装位置 / 重命名 / AppImage 换文件之后必须跟着更新,否则守护进程开机拉的是 - * 一个不存在的路径。 - */ -export async function syncGuiAutostartIntent(pipeName: string = DAEMON_PIPE_NAME): Promise { - if (!getHost().canAutostart) return; // 开发态 / 浏览器版:GUI 没有正确的 exe 语义 - const status = await daemonAutostartStatus(pipeName); - if (!status?.enabled) return; - const guiExe = getHost().currentExePath(); - if (!guiExe) return; - const result = await daemonAutostartSet({ enabled: true, gui_exe: guiExe }, pipeName); - if (result === null) return; - if (!result.ok) { - logger.warn('failed to refresh gui autostart path', { - event: 'daemon-gui-autostart-refresh-failed', - message: result.message, - }); - return; - } - logger.info('gui autostart path refreshed', { - event: 'daemon-gui-autostart-refreshed', - guiExe, - }); -} - -/** 让守护进程对某个请求做一次裸调用(统一管道入口;供状态页 / 诊断 / 未来命令使用)。 */ -export function callDaemonOnce( - request: DaemonRequest, - pipeName: string = DAEMON_PIPE_NAME, -): Promise { - return callDaemon(request, pipeName); -} diff --git a/apps/desktop/src/main/db_change_notification.ts b/apps/desktop/src/main/db_change_notification.ts new file mode 100644 index 000000000..fb1440e1e --- /dev/null +++ b/apps/desktop/src/main/db_change_notification.ts @@ -0,0 +1,25 @@ +/** 合并一小段时间内的变化,持续写库也不推迟首轮;销毁后不再投递。 */ +export function createDbChangeNotification(emit: (value: T) => void, delayMs = 200) { + let timer: ReturnType | undefined; + let latest: { value: T } | undefined; + let disposed = false; + return { + request(value: T) { + if (disposed) return; + latest = { value }; + if (timer !== undefined) return; + timer = setTimeout(() => { + timer = undefined; + const pending = latest; + latest = undefined; + if (pending && !disposed) emit(pending.value); + }, delayMs); + }, + dispose() { + disposed = true; + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + latest = undefined; + }, + }; +} diff --git a/apps/desktop/src/main/development_restart.ts b/apps/desktop/src/main/development_restart.ts new file mode 100644 index 000000000..88eb32ca2 --- /dev/null +++ b/apps/desktop/src/main/development_restart.ts @@ -0,0 +1,89 @@ +import { fork } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { manualRestartArgs } from './startup_window'; + +interface RestartApp { + on( + event: 'will-quit', + listener: (event: { defaultPrevented?: boolean; preventDefault(): void }) => void, + ): unknown; + removeListener( + event: 'will-quit', + listener: (event: { defaultPrevented?: boolean; preventDefault(): void }) => void, + ): unknown; + quit(): void; +} + +/** electron-vite 随原 Electron 退出;开发版必须重启整个工具链,而非沿用旧 URL。 */ +export async function prepareDevelopmentRestart( + app: RestartApp, + options: { appPath: string; argv: readonly string[]; logPath: string; nodePath?: string }, +): Promise { + if (!options.nodePath) + throw new Error('开发版重启需要 pnpm 的 Node 路径,请通过 pnpm 启动 WeQ。'); + await access(options.nodePath); + const worker = join(options.appPath, '../../scripts/restart-desktop-dev.mjs'); + await access(worker); + const helper = fork( + worker, + [ + JSON.stringify({ + parentPid: process.pid, + appPath: options.appPath, + cli: join(options.appPath, 'node_modules/electron-vite/bin/electron-vite.js'), + args: manualRestartArgs(options.argv).slice(1), + logPath: options.logPath, + }), + ], + { + execPath: options.nodePath, + detached: true, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }, + ); + await new Promise((resolve, reject) => { + let ready = false; + const cleanup = () => { + clearTimeout(timer); + app.removeListener('will-quit', commit); + helper.removeListener('error', fail); + helper.removeListener('exit', exited); + helper.removeListener('message', message); + }; + const fail = (error: Error) => { + cleanup(); + helper.kill(); + if (!ready) reject(error); + else console.error('开发版重启失败', error); + }; + const exited = () => fail(new Error('开发版重启助手提前退出')); + let committing = false; + const commit = (event: { defaultPrevented?: boolean; preventDefault(): void }) => { + if (event.defaultPrevented) return; + event.preventDefault(); + if (committing) return; + committing = true; + helper.send({ type: 'commit' }, (error) => { + if (error) fail(error); + }); + }; + const message = (value: unknown) => { + const reply = value as { type?: string; message?: string } | null; + if (reply?.type === 'ready' && !ready) { + ready = true; + app.on('will-quit', commit); + resolve(); + } else if (reply?.type === 'committed' && committing) { + cleanup(); + helper.disconnect(); + helper.unref(); + app.quit(); + } else if (reply?.type === 'error') fail(new Error(reply.message)); + }; + const timer = setTimeout(() => fail(new Error('开发版重启助手准备或退出超时')), 120_000); + helper.once('error', fail); + helper.once('exit', exited); + helper.on('message', message); + }); +} diff --git a/apps/desktop/src/main/directory_scan.ts b/apps/desktop/src/main/directory_scan.ts index f3b0fdb20..33989f9ae 100644 --- a/apps/desktop/src/main/directory_scan.ts +++ b/apps/desktop/src/main/directory_scan.ts @@ -6,7 +6,9 @@ export async function scanDirectories( directory: string, inspect: (path: string) => Promise, recursive = false, + signal?: AbortSignal, ) { + signal?.throwIfAborted(); const root = await realpath(directory); const queue = [{ path: root, depth: 0 }]; const items: T[] = []; @@ -14,6 +16,7 @@ export async function scanDirectories( let truncated = false; let unreadable = 0; for (let index = 0; index < queue.length; index++) { + signal?.throwIfAborted(); const current = queue[index]!; if (++visited > 4096) { truncated = true; @@ -23,6 +26,7 @@ export async function scanDirectories( try { found = await inspect(current.path); } catch (error) { + if (signal?.aborted) throw error; if (current.depth === 0) throw error; unreadable++; continue; @@ -38,6 +42,7 @@ export async function scanDirectories( try { const dir = await opendir(current.path); for await (const entry of dir) { + signal?.throwIfAborted(); if (!entry.isDirectory() || entry.isSymbolicLink()) continue; if (queue.length >= 4096) { truncated = true; @@ -46,6 +51,7 @@ export async function scanDirectories( queue.push({ path: join(current.path, entry.name), depth: current.depth + 1 }); } } catch (error) { + if (signal?.aborted) throw error; if (current.depth === 0) throw error; unreadable++; } diff --git a/apps/desktop/src/main/export_viewer.ts b/apps/desktop/src/main/export_viewer.ts index 8900bfd5f..6de3e12c8 100644 --- a/apps/desktop/src/main/export_viewer.ts +++ b/apps/desktop/src/main/export_viewer.ts @@ -2,12 +2,26 @@ import { randomUUID } from 'node:crypto'; import { discoverViewerSources, resolveViewerMedia, type ExportViewerSource } from '@weq/service'; import { scanDirectories } from './directory_scan'; -export async function discoverExportDirectories(path: string, recursive = false) { +export async function discoverExportDirectories( + path: string, + recursive = false, + options: { onSource?: (source: ExportViewerSource) => void; signal?: AbortSignal } = {}, +) { let partial = false; + const emitted = new Set(); const result = await scanDirectories( path, async (directory) => { - const catalog = await discoverViewerSources(directory, true); + const catalog = await discoverViewerSources(directory, true, { + signal: options.signal, + onSource: (source) => { + if (source.root !== directory && source.baselineDirectory !== directory) return; + if (emitted.has(source.path) || emitted.size >= 64) return; + options.signal?.throwIfAborted(); + emitted.add(source.path); + options.onSource?.(source); + }, + }); partial ||= catalog.truncated; // 旧基线目录索引也会列出子目录;这里按当前层过滤,避免漏掉同层的旧 JSON 导出。 return catalog.sources.filter( @@ -15,6 +29,7 @@ export async function discoverExportDirectories(path: string, recursive = false) ); }, recursive, + options.signal, ); return { sources: result.items, @@ -27,7 +42,8 @@ export async function discoverExportDirectories(path: string, recursive = false) const sources = new Map(); const controllers = new Map(); export function registerViewerSource(source: ExportViewerSource) { - if (sources.size >= 64) throw new Error('打开的导出过多,请关闭部分导出后重试。'); + // 一批最多 64 项,替换列表时短暂保留上一批,避免满列表无法打开新目录。 + if (sources.size >= 128) throw new Error('打开的导出过多,请关闭部分导出后重试。'); const id = randomUUID(); sources.set(id, source); controllers.set(id, new AbortController()); diff --git a/apps/desktop/src/main/hitokoto.ts b/apps/desktop/src/main/hitokoto.ts index fb4d349cf..ccd175ef9 100644 --- a/apps/desktop/src/main/hitokoto.ts +++ b/apps/desktop/src/main/hitokoto.ts @@ -39,12 +39,14 @@ function load(): HitokotoEntry[] { * 随机抽 n 条一言(去重、去空、句长适中——太长的做不了大字标题)。 * 资源缺失时返回空数组,调用方(系统提示)自动跳过该小节。 */ -export function sampleHitokoto(n: number): HitokotoVerse[] { +export function sampleHitokoto(n: number, blockedWords: readonly string[] = []): HitokotoVerse[] { const all = load(); if (!all.length) return []; + const blocked = blockedWords.map((word) => word.trim().toLowerCase()).filter(Boolean); const pool = all.filter((e) => { const t = e.hitokoto?.trim(); - return t && t.length >= 4 && t.length <= 40; + const content = `${t ?? ''}\n${e.from ?? ''}`.toLowerCase(); + return t && t.length >= 4 && t.length <= 40 && !blocked.some((word) => content.includes(word)); }); if (!pool.length) return []; diff --git a/apps/desktop/src/main/host.ts b/apps/desktop/src/main/host.ts index 9019d868c..fc3cc8f7f 100644 --- a/apps/desktop/src/main/host.ts +++ b/apps/desktop/src/main/host.ts @@ -6,16 +6,49 @@ import { app, dialog, shell, nativeImage, net } from 'electron'; import { spawn } from 'node:child_process'; -import { dirname, join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { homedir } from 'node:os'; import type { HostBridge, SaveTarget, StorageRoot } from '@weq/service'; import { clearStorageAfterExit, storageCleanupResult } from './storage_cleanup'; import { DialogLocations } from './dialog_locations'; +import { AppAutostart } from './autostart'; +import { manualRestartArgs } from './startup_window'; +import { skipNextQuitConfirmation } from './app_quit_confirmation'; +import { prepareDevelopmentRestart } from './development_restart'; + +let startup: AppAutostart | null = null; +function appAutostart(): AppAutostart { + startup ??= new AppAutostart({ + platform: process.platform, + home: homedir(), + env: process.env, + configDir: app.getPath('userData'), + exePath: process.env.APPIMAGE || app.getPath('exe'), + supported: app.isPackaged, + native: { + get: (options) => app.getLoginItemSettings(options), + set: (options) => app.setLoginItemSettings(options), + }, + }); + return startup; +} + +export function migrateLegacyAutostart(): Promise { + return appAutostart().migrateLegacy(); +} export function dialogLocations(): DialogLocations { return new DialogLocations(join(app.getPath('userData'), 'dialog-locations.json')); } +async function defaultSaveDirectory(selected?: string): Promise { + if (selected) return selected; + const { getAppContext } = await import('./context/app_context'); + return ( + getAppContext().bootstrap?.userConfig.getSettings().defaultExportDir || app.getPath('desktop') + ); +} + /** * `shell.openPath` 在 Windows / Linux 上都会等待系统处理程序返回: * - Windows:Explorer / “打开方式”对话框(Electron 36.8 才合入 non-blocking @@ -103,8 +136,18 @@ export const electronHost: HostBridge = { clearStorageAfterExit, storageCleanupResult, restartApplication: async () => { - app.relaunch(); - setTimeout(() => app.quit(), 200); + if (!app.isPackaged && process.env.ELECTRON_RENDERER_URL) { + await prepareDevelopmentRestart(app, { + appPath: app.getAppPath(), + argv: process.argv, + nodePath: process.env.npm_node_execpath, + logPath: join(app.getPath('logs'), 'development-restart.log'), + }); + } else app.relaunch({ args: manualRestartArgs(process.argv) }); + setTimeout(() => { + skipNextQuitConfirmation(); + app.quit(); + }, 200); }, fetchMedia: (url, opts) => net.fetch(url, { signal: opts?.signal }), async validateImageBuffer(bytes) { @@ -117,10 +160,12 @@ export const electronHost: HostBridge = { canReveal: true, async pickDirectory(opts) { - const key = `directory:${opts?.title ?? 'default'}`; + const key = `directory:${opts?.locationKey ?? opts?.title ?? 'default'}`; const result = await dialog.showOpenDialog({ title: opts?.title, - defaultPath: dialogLocations().directory(key), + defaultPath: + dialogLocations().directory(key) ?? + (opts?.locationKey ? await defaultSaveDirectory(opts.defaultDirectory) : undefined), properties: ['openDirectory', 'createDirectory'], }); if (result.canceled || result.filePaths.length === 0) return null; @@ -143,10 +188,18 @@ export const electronHost: HostBridge = { return result.filePaths[0] ?? null; }, - async pickSaveTarget({ defaultName, extension, locationKey }): Promise { + async pickSaveTarget({ + defaultName, + extension, + locationKey, + defaultDirectory, + }): Promise { const key = `save:${locationKey ?? extension ?? 'default'}`; const result = await dialog.showSaveDialog({ - defaultPath: dialogLocations().savePath(key, defaultName), + defaultPath: join( + dialogLocations().directory(key) ?? (await defaultSaveDirectory(defaultDirectory)), + basename(defaultName), + ), filters: extension ? [{ name: 'File', extensions: [extension] }] : undefined, }); if (result.canceled || !result.filePath) return null; @@ -195,10 +248,10 @@ export const electronHost: HostBridge = { appVersion: () => app.getVersion(), isPackaged: () => app.isPackaged, - // 「开机拉起 WeQ」的意图交给守护进程记着(GUI 自己不注册任何原生自启): - // pnpm dev 拉起的 electron 没有安装、没有固定路径语义,让它开机起是错的 —— - // 只允许打包版开启。 + // 系统直接启动安装版 WeQ;开发态没有稳定的安装路径。 canAutostart: app.isPackaged, + getAutostart: () => appAutostart().status(), + setAutostart: (enabled) => appAutostart().set(enabled), currentExePath: () => { // AppImage 下 `app.getPath('exe')` 是本次运行的临时挂载点 // (`/tmp/.mount_./…`),重启后必然不存在 —— 必须用 $APPIMAGE diff --git a/apps/desktop/src/main/html_load.ts b/apps/desktop/src/main/html_load.ts index b59dc0aaa..6a9122cf0 100644 --- a/apps/desktop/src/main/html_load.ts +++ b/apps/desktop/src/main/html_load.ts @@ -26,12 +26,29 @@ import type { BrowserWindow } from 'electron'; let seq = 0; /** 把自包含 HTML 落盘成临时文件,用 file:// 装进 win;返回加载完成的 Promise。 */ -export async function loadIsolatedHtml(win: BrowserWindow, html: string): Promise { +export async function loadIsolatedHtml( + win: BrowserWindow, + html: string, + options?: { offline?: boolean }, +): Promise { seq += 1; const file = join( tmpdir(), `weq-html-${process.pid}-${seq}-${randomBytes(8).toString('hex')}.html`, ); + const url = pathToFileURL(file).href; + if (options?.offline) { + // 捕获窗口使用独立的内存 session;不让远程 HTML 读取宿主文件、网络或设备权限。 + win.webContents.session.webRequest.onBeforeRequest((details, callback) => { + callback({ cancel: details.url !== url && !details.url.startsWith('data:') }); + }); + win.webContents.session.setPermissionRequestHandler((_contents, _permission, callback) => + callback(false), + ); + win.webContents.session.setPermissionCheckHandler(() => false); + win.webContents.on('will-navigate', (event) => event.preventDefault()); + win.webContents.on('will-redirect', (event) => event.preventDefault()); + } await writeFile(file, html, 'utf8'); const cleanup = (): void => { void rm(file, { force: true }).catch(() => {}); @@ -39,7 +56,7 @@ export async function loadIsolatedHtml(win: BrowserWindow, html: string): Promis // 页面活着时不能删(还要重排 / 重绘);窗口关闭即回收。 win.once('closed', cleanup); try { - await win.loadURL(pathToFileURL(file).href); + await win.loadURL(url); } catch (error) { cleanup(); throw error; diff --git a/apps/desktop/src/main/html_shot.ts b/apps/desktop/src/main/html_shot.ts index 3b9a24ded..63f55e447 100644 --- a/apps/desktop/src/main/html_shot.ts +++ b/apps/desktop/src/main/html_shot.ts @@ -25,6 +25,7 @@ */ import { BrowserWindow, nativeImage, type NativeImage } from 'electron'; +import { randomUUID } from 'node:crypto'; import { loadIsolatedHtml } from './html_load'; /** 加载 + 字体就绪的硬超时:无论成功与否都不把窗口留在后台。 */ @@ -102,6 +103,7 @@ async function openWindow(html: string): Promise { height: 1300, show: false, webPreferences: { + partition: `weq-report-shot-${randomUUID()}`, sandbox: true, contextIsolation: true, nodeIntegration: false, @@ -111,7 +113,7 @@ async function openWindow(html: string): Promise { }); win.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); try { - const loaded = loadIsolatedHtml(win, html).catch(() => {}); + const loaded = loadIsolatedHtml(win, html, { offline: true }).catch(() => {}); await Promise.race([loaded, delay(LOAD_TIMEOUT_MS)]); if (win.isDestroyed()) throw new Error('截图窗口已被销毁'); await win.webContents diff --git a/apps/desktop/src/main/image_preview.ts b/apps/desktop/src/main/image_preview.ts index 4cebf28e2..8dcd365cf 100644 --- a/apps/desktop/src/main/image_preview.ts +++ b/apps/desktop/src/main/image_preview.ts @@ -6,7 +6,9 @@ const MAX_INPUT_BYTES = 32 * 1024 * 1024; const MAX_CACHE_BYTES = 16 * 1024 * 1024; /** 在读取原图前排队;缓存按内容共享,不能用 CSS 缩放代替像素限制。 */ -export function createImagePreviewer(options: { maxPending?: number; maxPixels?: number } = {}) { +export function createImagePreviewer( + options: { maxPending?: number; maxPixels?: number; preserveAnimation?: boolean } = {}, +) { const waiting: (() => void)[] = []; const cache = new Map(); let cacheBytes = 0; @@ -20,6 +22,10 @@ export function createImagePreviewer(options: { maxPending?: number; maxPixels?: const input = await load(); if (input.byteLength === 0 || input.byteLength > MAX_INPUT_BYTES) throw new Error('preview input size'); + if (options.preserveAnimation) { + const metadata = await sharp(input).metadata(); + if ((metadata.pages ?? 1) > 1) throw new Error('animated preview'); + } const key = createHash('sha256').update(input).digest('hex'); const hit = cache.get(key); if (hit) { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1d00a8d4b..4b6dd7f71 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -15,10 +15,12 @@ import { net, protocol, shell, + screen, Tray, type IpcMainEvent, } from 'electron'; import fs from 'node:fs'; +import { WINDOW_LAYOUTS, applyWindowLayout, type WindowLayout } from './window_layout'; import { electronApp, optimizer, is } from '@electron-toolkit/utils'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -47,13 +49,12 @@ import { getAppContext } from './context/app_context'; import { handleGroupKeywordHit } from './group_keyword_notify'; import { handleRecallNotification } from './recall_notify'; import { handleMailNotification } from './mail_notify'; -import { checkForUpdate, installUpdateActions } from './update/updater'; +import { checkForUpdate, installUpdateActions, isInstallingUpdate } from './update/updater'; import { stopMcpServer } from './mcp/server'; import { remoteRuntime } from './remote/runtime'; import { desktopWebRuntime } from './web/runtime'; import { registerWeqAssistantIpc } from './weq_assistant/ipc'; -import { startReleaseMonitor } from './daemon/release_monitor'; -import { ensureDaemonRunning, syncGuiAutostartIntent } from './daemon/runtime'; +import { assistantResourceServer } from './weq_assistant/server'; import { disposeExternalMcp } from './mcp/external'; import { disposeElevatedCaptures } from './ipc/routers/wonderful_tools'; import { registerAnalyticsExportIpc } from './analytics_export'; @@ -62,13 +63,26 @@ import { registerQzoneIpc } from './qzone'; import { registerFlashShareIpc } from './flash_share'; import { getMainWindow, setMainWindow } from './main_window'; import { createMainWindowLifecycle } from './main_window_lifecycle'; -import { registerAppQuitPreparation } from './app_quit_lifecycle'; +import { + AUTOSTART_ARG, + installStartupWindowReveal, + isAutostartLaunch, + shouldShowStartupWindow, +} from './startup_window'; +import { + registerAppQuitCleanup, + registerAppQuitPreparation, + registerWindowUnloadConfirmation, +} from './app_quit_lifecycle'; +import { registerAppQuitConfirmation, type QuitChoice } from './app_quit_confirmation'; import { createRendererRecovery } from './renderer_recovery'; import { startRendererDiagnostics } from './renderer_diagnostics_runtime'; import { macApplicationMenu, nativeMenuLabels } from './application_menu'; import { registerEditUndoIpc, undoFocusedContent, redoFocusedContent } from './edit_undo'; +import { registerCallVideoIpc } from './call_video_ipc'; import { registerAttachHintIpc } from './attach_hint_ipc'; import { registerElevationIpc } from './elevation_ipc'; +import { registerLongTermAuthorizationIpc } from './authorization/ipc'; import { getLogDir, getLogger, @@ -76,7 +90,7 @@ import { type ExportSavePolicy, type WindowCloseBehavior, } from '@weq/service'; -import { electronHost } from './host'; +import { electronHost, migrateLegacyAutostart } from './host'; import { mediaSaveRouter } from './ipc/routers/media_save'; import { systemAuthService } from './system_auth'; import { totpAuthService } from './totp_auth'; @@ -114,18 +128,6 @@ const { createIPCHandler } = requireFromHere( 'electron-trpc/main', ) as typeof import('electron-trpc/main'); -/** - * Per-view window sizes. The home/bootstrap screen is compact; the chat view - * gets a bit more room. Switching views resizes the window (see the - * `window:set-layout` IPC), keeping the top-left corner fixed. - */ -const WINDOW_LAYOUTS = { - home: { width: 1120, height: 580 }, - chat: { width: 1180, height: 760 }, -} as const; - -type WindowLayout = keyof typeof WINDOW_LAYOUTS; - const logger = getLogger().child({ scope: 'desktop-main' }); /** Created once; reopening attaches a window without registering IPC again. */ @@ -137,6 +139,7 @@ let tray: Tray | null = null; * and lets the window actually close (托盘退出 / 确认框「完全退出」/ before-quit). */ let isQuitting = false; let stopRendererDiagnostics: (() => void) | null = null; +let mainWindowRequested = false; const mainWindowLifecycle = createMainWindowLifecycle({ canOpen: () => mainIpcHandler !== null && !isQuitting, @@ -147,6 +150,7 @@ const mainWindowLifecycle = createMainWindowLifecycle({ }); function openMainWindow(): void { + mainWindowRequested = true; mainWindowLifecycle.open(); } @@ -192,7 +196,6 @@ function buildTray(): void { { label: labels.quit, click: () => { - isQuitting = true; app.quit(); }, }, @@ -216,12 +219,8 @@ function registerWindowLayoutIpc(): void { }); ipcMain.handle('window:set-layout', (event, layout: WindowLayout) => { const win = BrowserWindow.fromWebContents(event.sender); - const size = WINDOW_LAYOUTS[layout] ?? WINDOW_LAYOUTS.home; - // Don't fight a user who maximized/fullscreened the window. - if (!win || win.isMaximized() || win.isFullScreen()) return; - const [w, h] = win.getContentSize(); - if (w === size.width && h === size.height) return; - win.setContentSize(size.width, size.height, true); + if (!win) return; + applyWindowLayout(win, layout, screen.getDisplayMatching(win.getBounds()).workArea); }); ipcMain.on('window-minimize', (event) => { @@ -248,7 +247,6 @@ function registerWindowLayoutIpc(): void { if (action === 'tray') { getMainWindow()?.hide(); } else if (action === 'quit') { - isQuitting = true; app.quit(); } }); @@ -430,11 +428,30 @@ function registerLogIpc(): void { return true; }); - // 守护进程设置页「打开 docroot」——只在路径确实指向磁盘上已存在的目录时放行。 - ipcMain.handle('daemon:reveal-path', async (_event, path?: string) => { - if (typeof path !== 'string' || !path || !fs.existsSync(path)) return false; - await electronHost.revealPath(path); - return true; + /** + * 渲染层 → 主进程日志文件(`weq.debugLog`)。 + * + * 通话视频的解码全在渲染层,而渲染层的 `console.*` **不会**进日志文件 —— + * 「等待对方画面」这类问题因此一直只能盲猜。这个通道只接受结构化字段, + * 走同一个 `getLogger()`,落进同一天的日志里,便于和主进程的 `scope=call` + * 行按时间对齐。 + * + * 刻意**不**转发 `console` 全量:主进程 `console-message` 会把整个渲染层的 + * 噪声(React 警告、第三方库日志)都灌进文件。调用方显式调用才记。 + */ + ipcMain.on('logs:renderer', (_event, payload: unknown) => { + if (!payload || typeof payload !== 'object') return; + const record = payload as { level?: unknown; message?: unknown; context?: unknown }; + if (typeof record.message !== 'string' || record.message.length > 2000) return; + const level = + record.level === 'warn' || record.level === 'error' || record.level === 'debug' + ? record.level + : 'info'; + const context = + record.context && typeof record.context === 'object' + ? (record.context as Record) + : {}; + logger[level](record.message, { scope: 'renderer', ...context }); }); } @@ -499,7 +516,7 @@ function resolveWindowIcon(): Electron.NativeImage | undefined { return img.isEmpty() ? undefined : img; } -function createWindow(): BrowserWindow { +function createWindow(showInitially = true): BrowserWindow { const icon = resolveWindowIcon(); // On tiling Wayland WMs (Hyprland/sway) new toplevels tile by default. Ask // the WM to float us by advertising a non-'normal' window type — 'toolbar' @@ -507,11 +524,8 @@ function createWindow(): BrowserWindow { // 'splash', it keeps the taskbar entry). Opt-out with WEQ_WINDOW_TYPE=normal // (or override to another value) so this can be disabled per-environment. const win = new BrowserWindow({ - width: 1120, - height: 580, - minWidth: 940, - minHeight: 520, - show: true, + ...WINDOW_LAYOUTS.home, + show: showInitially, title: 'WeQ Desktop', autoHideMenuBar: true, backgroundColor: '#f0f0f0', @@ -547,7 +561,6 @@ function createWindow(): BrowserWindow { }, reload: () => win.webContents.reload(), quit: () => { - isQuitting = true; app.quit(); }, record: (event, details) => @@ -561,24 +574,7 @@ function createWindow(): BrowserWindow { win.webContents.on('did-finish-load', () => recovery.loaded()); win.once('closed', () => recovery.dispose()); - const reveal = () => { - if (win.isDestroyed()) return; - win.show(); - win.restore(); - win.focus(); - try { - win.setAlwaysOnTop(true); - win.setAlwaysOnTop(false); - } catch { - /* ignore */ - } - }; - - win.on('ready-to-show', reveal); - // Fallback: in some environments `ready-to-show` can be delayed or missed - // (e.g. compositor/driver quirks). Guarantee visibility once content loads. - win.webContents.on('did-finish-load', reveal); - setTimeout(reveal, 1000); + installStartupWindowReveal(win, showInitially); // Apply the user-configured close action (设置 → 全局设置 → 关闭按钮): // 'quit' → quit the app on every platform; @@ -593,7 +589,7 @@ function createWindow(): BrowserWindow { } const behavior = resolveCloseBehavior(); if (behavior === 'quit') { - isQuitting = true; + e.preventDefault(); app.quit(); return; } @@ -656,7 +652,13 @@ const hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!hasSingleInstanceLock) { app.quit(); } else { - app.on('second-instance', openMainWindow); + app.on('second-instance', (_event, argv) => { + if ( + !argv.includes(AUTOSTART_ARG) || + (mainIpcHandler && getAppContext().bootstrap?.userConfig.getSettings().showWindowOnAutostart) + ) + openMainWindow(); + }); } void app.whenReady().then(async () => { @@ -685,6 +687,13 @@ void app.whenReady().then(async () => { console.error('远程连接恢复失败', error instanceof Error ? error.message : '未知错误'), ); if (!hasSingleInstanceLock) return; + const autostart = + app.isPackaged && + isAutostartLaunch( + process.argv, + process.platform, + process.platform === 'darwin' && app.getLoginItemSettings().wasOpenedAtLogin, + ); electronApp.setAppUserModelId('app.weq.desktop'); // win32: resolve QQ's tencent:// handler (timwp.exe) before building the @@ -693,19 +702,38 @@ void app.whenReady().then(async () => { if (process.platform !== 'linux') { await probeQqProtocolHandler(); } + if (isQuitting) return; // Order matters: AppContext (loads native + platform) before IPC handler. // The host bridge must be installed before any router runs. setHost(electronHost); + await migrateLegacyAutostart().catch((error: unknown) => { + logger.warn('legacy daemon migration failed', { + event: 'legacy-daemon-migration-failed', + error: error instanceof Error ? error.message : String(error), + }); + }); + if (isQuitting) return; installUpdateActions(); initAppContext(); const userConfig = getAppContext().bootstrap?.userConfig; + if (userConfig?.getSettings().weqAssistant.enabled) { + void getAppContext() + .applyWeqAssistant(userConfig.getSettings().weqAssistant) + .catch((error: unknown) => { + logger.warn('assistant resource startup failed', { + event: 'assistant-resource-startup-failed', + error: String(error), + }); + }); + } if (userConfig) { desktopWebRuntime.configure({ publicDir: app.isPackaged ? join(process.resourcesPath, 'remote-web') : join(app.getAppPath(), '../web/dist/public'), exportDir: join(app.getPath('userData'), 'web-exports'), + renderer: electronHost, version: app.getVersion(), router: appRouter, pairing: remoteRuntime.pairing, @@ -766,10 +794,28 @@ void app.whenReady().then(async () => { registerWeqAssistantIpc(); registerAttachHintIpc(); registerElevationIpc(); + registerLongTermAuthorizationIpc(); registerEditUndoIpc(); + registerCallVideoIpc(); app.on('browser-window-created', (_, win) => { optimizer.watchWindowShortcuts(win); + registerWindowUnloadConfirmation(win.webContents, { + confirmDiscard: () => + dialog.showMessageBoxSync(win, { + type: 'warning', + title: '存在未保存的更改', + message: '放弃此窗口中未保存的更改?', + buttons: ['继续编辑', '放弃更改'], + defaultId: 0, + cancelId: 0, + noLink: true, + }) === 1, + cancelled: () => { + isQuitting = false; + resetQuitPreparation(); + }, + }); }); mainIpcHandler = createIPCHandler({ @@ -781,9 +827,16 @@ void app.whenReady().then(async () => { return {}; }, }); - openMainWindow(); - logger.info('main window created', { event: 'create-window' }); buildTray(); + mainWindowLifecycle.open({ + show: shouldShowStartupWindow({ + autostart, + showWindowOnAutostart: userConfig?.getSettings().showWindowOnAutostart ?? false, + trayAvailable: tray !== null, + manualRequested: mainWindowRequested, + }), + }); + logger.info('main window created', { event: 'create-window', autostart }); if (process.platform === 'darwin') { Menu.setApplicationMenu( Menu.buildFromTemplate( @@ -804,25 +857,6 @@ void app.whenReady().then(async () => { setTimeout(() => void checkForUpdate(true).catch(() => {}), 3000); } - // 守护进程 release 提醒循环(发现新版本弹系统通知):始终挂着,每 30s 读一次 - // 守护进程状态;守护进程侧轮询未开启 / 不在时自然为 no-op。推文由打包版 - // 应用内更新检查(update_tweet)统一负责,不在这里重复写。 - startReleaseMonitor(); - - // 守护进程默认拉起:应用启动即检查 / 拉起 weq-daemon(含版本对齐与稳定落位), - // 不再等「WeQ 助手」开关。best-effort:二进制缺失 / 拉起失败只记日志,不阻塞 - // 启动;设置页展示实时状态。 - void ensureDaemonRunning() - // 起来之后,再把「开机拉起 WeQ」的意图 + 当前 exe 路径刷进守护进程记忆。 - // GUI 自己不注册任何原生自启 —— 全机唯一的自启注册是守护进程那一份。 - .then(() => syncGuiAutostartIntent()) - .catch((error) => { - logger.warn('failed to ensure weq-daemon on startup', { - event: 'daemon-startup-failed', - errorMessage: error instanceof Error ? error.message : String(error), - }); - }); - app.on('activate', openMainWindow); }); @@ -834,9 +868,80 @@ app.on('window-all-closed', () => { }); // A real quit is underway (tray「退出」/ 确认框「完全退出」/ Cmd+Q / OS shutdown): -// flip the flag so the `close` handler stops intercepting, and drop the tray. -registerAppQuitPreparation(app, autoUpdater, () => { +// 只放行窗口关闭;未保存内容仍可能取消退出,暂不停止服务或销毁托盘。 +const resetQuitPreparation = registerAppQuitPreparation(app, autoUpdater, () => { isQuitting = true; +}); + +// 助手资源随应用结束;次实例退出不能关闭主实例拥有的服务。 +if (hasSingleInstanceLock) { + registerAppQuitConfirmation(app, autoUpdater, { + needsConfirmation: () => { + try { + return getAppContext().bootstrap?.userConfig.getSettings().weqAssistant.enabled ?? false; + } catch { + return false; + } + }, + bypass: () => isInstallingUpdate(), + showDialog: async (): Promise => { + const options: Electron.MessageBoxOptions = { + type: 'warning', + title: '确认退出 WeQ', + message: 'WeQ 助手已启用,仍要完全退出吗?', + detail: + '完全退出后,QQ 中的助手卡片将无法加载本机封面和页面。只关闭窗口会保留 WeQ 与助手服务运行。', + buttons: ['取消', '只关闭窗口', '确认退出'], + defaultId: 1, + cancelId: 0, + noLink: true, + }; + const window = getMainWindow(); + const result = + window && !window.isDestroyed() && window.isVisible() + ? await dialog.showMessageBox(window, options) + : await dialog.showMessageBox(options); + return result.response === 2 ? 'quit' : result.response === 1 ? 'window' : 'cancel'; + }, + hideWindow: () => getMainWindow()?.hide(), + failed: (error) => + logger.warn('quit confirmation failed', { + event: 'quit-confirmation-failed', + error: String(error), + }), + }); + registerAppQuitCleanup( + app, + async () => { + await Promise.all([ + assistantResourceServer.shutdown(), + getAppContext().services?.unreadInfo.dispose(), + ]); + }, + (error) => { + logger.warn('failed to stop assistant resources on quit', { + event: 'assistant-resource-quit-failed', + error: String(error), + }); + }, + autoUpdater, + ); +} + +// 账号没有显式关闭时,也释放本轮 Web、Remote、MCP 与提权辅助进程。 +app.on('will-quit', (event) => { + if (event.defaultPrevented) return; + if (hasSingleInstanceLock) { + try { + const ctx = getAppContext(); + ctx.bootstrap?.userConfig.setSkipAutoEnterOnce(!ctx.account); + } catch (error) { + logger.warn('failed to save login exit state', { + event: 'login-exit-state-failed', + error: String(error), + }); + } + } stopRendererDiagnostics?.(); stopRendererDiagnostics = null; try { @@ -855,14 +960,7 @@ registerAppQuitPreparation(app, autoUpdater, () => { } tray = null; } -}); - -// Best-effort: stop the account-bound MCP server on quit even if the account -// was never explicitly closed (clearAccount also stops it). The weq-daemon -// companion process is deliberately NOT touched on quit — its lifecycle -// belongs to autostart / the user, and it keeps serving the QQ cards after -// WeQ is gone. -app.on('will-quit', () => { + void assistantResourceServer.shutdown(); remoteRuntime.stop(); desktopWebRuntime.stop(); void stopMcpServer(); diff --git a/apps/desktop/src/main/intent_sense/bridge.ts b/apps/desktop/src/main/intent_sense/bridge.ts index fa35fa9dd..cfc6ab69a 100644 --- a/apps/desktop/src/main/intent_sense/bridge.ts +++ b/apps/desktop/src/main/intent_sense/bridge.ts @@ -34,7 +34,11 @@ export interface IntentSenseLocalEngine extends IntentLocalEnginePort { /** 模型 bundle 落盘目录(weq 用户数据目录下)。 */ modelDir(): string; isModelDownloaded(): boolean; - download(options: { mirror: boolean; onProgress?: (p: BundleProgress) => void }): Promise; + download(options: { + modelId?: string; + mirror: boolean; + onProgress?: (p: BundleProgress) => void; + }): Promise; start(): Promise; stop(): Promise; } @@ -75,8 +79,9 @@ export function qweqModelCandidates(): string[] { } /** 探测优先可用的本地模型目录:优先使用自研微调模型 QWeQ-NineBird-v1,未就绪则回落到 Laya。 */ -export function findAvailableModelDir(): string { - const selected = getAppContext().bootstrap?.userConfig.getLocalModelId('intent') ?? 'auto'; +export function findAvailableModelDir( + selected = getAppContext().bootstrap?.userConfig.getLocalModelId('intent') ?? 'auto', +): string { if (selected === 'laya-multilingual') return layaModelDir(); for (const dir of qweqModelCandidates()) { if (existsSync(join(dir, 'model.onnx')) && existsSync(join(dir, 'model_config.json'))) { @@ -117,12 +122,25 @@ const REQUEST_TIMEOUT_MS = 120_000; const LOAD_TIMEOUT_MS = 120_000; class LocalEngineBridge implements IntentLocalEnginePort { + constructor(private readonly modelDir: string) {} private worker: ReturnType | null = null; private readyFlag = false; private starting: Promise | null = null; private startFailed = false; private nextId = 1; private readonly pending = new Map(); + private idleTimer: ReturnType | null = null; + + /** 多资源可共存;空闲后释放 worker,模型文件仍复用。 */ + private touch(): void { + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = setTimeout(() => { + this.idleTimer = null; + if (this.pending.size || this.starting) this.touch(); + else void this.stop(); + }, 5 * 60_000); + this.idleTimer.unref?.(); + } /** * 对 service 层暴露的就绪态:worker 已就绪,或本地模型文件齐全且未发生启动失败(调用 analyze 时按需拉起 worker)。 @@ -130,7 +148,7 @@ class LocalEngineBridge implements IntentLocalEnginePort { get ready(): boolean { if (this.readyFlag && this.worker !== null) return true; if (this.startFailed) return false; - return isAnyModelReady(findAvailableModelDir()); + return isAnyModelReady(this.modelDir); } get workerRunning(): boolean { @@ -138,6 +156,7 @@ class LocalEngineBridge implements IntentLocalEnginePort { } async start(modelDir: string): Promise { + this.touch(); if (this.workerRunning) return; if (this.starting) return this.starting; this.startFailed = false; @@ -150,13 +169,14 @@ class LocalEngineBridge implements IntentLocalEnginePort { this.starting = null; }); this.starting = task; - return task; + await task; + this.touch(); } private async ensureWorkerStarted(): Promise { if (this.workerRunning) return true; if (this.startFailed) return false; - const dir = findAvailableModelDir(); + const dir = this.modelDir; if (!isAnyModelReady(dir)) return false; try { await this.start(dir); @@ -190,11 +210,13 @@ class LocalEngineBridge implements IntentLocalEnginePort { let exited = false; worker.on('exit', (code) => { exited = true; + if (this.worker !== worker) return; this.worker = null; this.readyFlag = false; this.failAll(new Error(`意图感知本地引擎已退出 (code ${code ?? '?'})`)); }); worker.on('error', (error) => { + if (this.worker !== worker) return; this.readyFlag = false; this.failAll(error); }); @@ -226,6 +248,8 @@ class LocalEngineBridge implements IntentLocalEnginePort { } async stop(): Promise { + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; const worker = this.worker; this.worker = null; this.readyFlag = false; @@ -247,6 +271,7 @@ class LocalEngineBridge implements IntentLocalEnginePort { sentiment: number; confidence: number; } | null> { + this.touch(); if (!(await this.ensureWorkerStarted())) return null; try { const msg = (await this.request({ @@ -270,6 +295,7 @@ class LocalEngineBridge implements IntentLocalEnginePort { } async inferMbti(samples: string[]): Promise { + this.touch(); if (samples.length === 0 || !(await this.ensureWorkerStarted())) return null; try { const msg = (await this.request({ type: 'mbti', samples })) as WorkerMessage; @@ -386,10 +412,14 @@ function normalizeEmotion(v: unknown): EmotionKind { // ── 单例门面(electron 入口 index.ts 注入给 app_context;web 永不注入)── -let bridge: LocalEngineBridge | null = null; +const bridges = new Map(); -function ensureBridge(): LocalEngineBridge { - if (!bridge) bridge = new LocalEngineBridge(); +function ensureBridge(dir = findAvailableModelDir()): LocalEngineBridge { + let bridge = bridges.get(dir); + if (!bridge) { + bridge = new LocalEngineBridge(dir); + bridges.set(dir, bridge); + } return bridge; } @@ -400,13 +430,14 @@ function ensureBridge(): LocalEngineBridge { * 不能进 router / app_context 的(web 共享的)模块图。 */ export function getIntentSenseLocalEngine(): IntentSenseLocalEngine { - const impl = ensureBridge(); return { get ready() { - return impl.ready; + return ensureBridge().ready; }, - analyze: (text: string, options?: IntentLocalAnalyzeOptions) => impl.analyze(text, options), - inferMbti: (samples: string[]) => impl.inferMbti(samples), + forModel: (modelId) => ensureBridge(findAvailableModelDir(modelId)), + analyze: (text: string, options?: IntentLocalAnalyzeOptions) => + ensureBridge().analyze(text, options), + inferMbti: (samples: string[]) => ensureBridge().inferMbti(samples), modelDir: () => findAvailableModelDir(), listModels: () => [ { @@ -438,7 +469,10 @@ export function getIntentSenseLocalEngine(): IntentSenseLocalEngine { ], isModelDownloaded: () => isAnyModelReady(findAvailableModelDir()), download: (options) => { - const selected = getAppContext().bootstrap?.userConfig.getLocalModelId('intent') ?? 'auto'; + const selected = + options.modelId ?? + getAppContext().bootstrap?.userConfig.getLocalModelId('intent') ?? + 'auto'; if (selected === 'QWeQ-NineBird-v1') throw new Error('自研模型由安装包或已有模型目录提供,暂不支持在线下载'); return downloadLayaBundle({ @@ -452,8 +486,10 @@ export function getIntentSenseLocalEngine(): IntentSenseLocalEngine { if (!isAnyModelReady(dir)) { throw new Error('本地模型尚未就绪'); } - await impl.start(dir); + await ensureBridge(dir).start(dir); + }, + stop: async () => { + await Promise.all([...bridges.values()].map((bridge) => bridge.stop())); }, - stop: () => impl.stop(), }; } diff --git a/apps/desktop/src/main/ipc/routers/account.ts b/apps/desktop/src/main/ipc/routers/account.ts index 3319eec0d..78af1820a 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -12,12 +12,21 @@ */ import { z } from 'zod'; +import { clearCompletedTasks } from './clear_completed_tasks'; +import { FileUploadStaging } from './album_video_staging'; +import { + BROWSER_FILE_CHUNK_BYTES, + BROWSER_FILE_MAX_BYTES, +} from '../../../shared/browser_file_upload'; +import { assertEmbeddingModelIdentity, AGENTLAB_PERSONA_ID_PATTERN } from '@weq/agentlab'; +import { agentLabModelSelectionSchema } from '../../../shared/agentlab_model_selection'; import { SEARCH_CATEGORIES, saveExportOutput, exportOutputFileName, describeExportOutput, resolveVipBadge, + isCompleteSessionMaterial, } from '@weq/service'; import { exportDownloadName, @@ -46,15 +55,18 @@ import { type AccountServices, type RoamingCacheChange, } from '../../context/app_context'; +import { fullModeAllowed, requestFullMode } from '../../context/full_mode'; import { onlineTranscriber } from '../../online_transcribe'; import type { QuarantinedTable } from '@weq/native'; import type { SalvageLedgerEntry } from '@weq/db'; +import { databasePerformance } from '@weq/db'; import { classifyChatType, ProtoMsg } from '@weq/codec'; import { WalletFlag48417Wire } from '@weq/codec/proto/msg/element'; -import { detectThumbType, type OcrBlock } from '@weq/protocol'; +import { buildShareLocationElements, detectThumbType, type OcrBlock } from '@weq/protocol'; /** 48417 的嵌套块解码器(红包定位:orderId + packetId)。 */ const walletFlag48417Wire = new ProtoMsg(WalletFlag48417Wire); +const agentLabPersonaId = z.string().regex(AGENTLAB_PERSONA_ID_PATTERN, '克隆 ID 无效'); import { sampleHitokoto } from '../../hitokoto'; import type { TranscribeResult } from '../../transcribe/engine'; import { @@ -321,7 +333,7 @@ async function sampleHomeChatLines( return pool.slice(0, limit); } -const agentLabModelRef = z.object({ providerId: z.string().min(1), model: z.string().min(1) }); +const agentLabModelRef = agentLabModelSelectionSchema; const cloneQuantity = (key: CloneGenerationKey) => z.number().int().min(CLONE_GENERATION[key].min).max(CLONE_GENERATION[key].max).optional(); const agentLabModels = z.object({ @@ -538,9 +550,10 @@ export interface GroupJoinResult { } export interface GroupAlbumAccessState { + source?: { expectedAccountUin: string; expectedAccountConfigId: string }; qqOnline: boolean; qqPid: number | null; - /** 「自动读取 QQ 内存(完整功能)」总闸——关闭即完全离线模式,在线功能不可用。 */ + /** 宿主内有效的自动策略或本来源手动请求,不表示凭据已经就绪。 */ attachEnabled: boolean; /** * 本地账号配置里的会话物料是否齐全(a2 / d2 / d2key)。发包要拿它做 TEA 加密, @@ -688,22 +701,25 @@ async function withRoamingHistory( services: AccountServices, kind: ChatKind, conv: string, - rows: ChatMsgWire[], + rows: ChatMsgWire[] | Promise, limit: number, min = 1n, max = 0xffffffffn, descending = true, ): Promise { try { - const cached = await services.gapHistory.cachedTimeline( - kind, - conv, - Number(min < 1n ? 1n : min), - Number(max > 0xffffffffn ? 0xffffffffn : max), - limit, - descending, - ); - return mergeRoamingTimeline(kind, rows, cached.map(roamingMsgToWire), { + const [local, cached] = await Promise.all([ + rows, + services.gapHistory.cachedTimeline( + kind, + conv, + Number(min < 1n ? 1n : min), + Number(max > 0xffffffffn ? 0xffffffffn : max), + limit, + descending, + ), + ]); + return mergeRoamingTimeline(kind, local, cached.map(roamingMsgToWire), { min, max, limit, @@ -720,8 +736,8 @@ async function fetchLatest(kind: ChatKind, conv: string, limit: number): Promise const msgs = services.msgs; const rows = kind === 'group' - ? (await msgs.getGroupLatest(conv, limit)).map(groupMsgToWire) - : (await msgs.getC2cLatest(conv, limit)).map(c2cMsgToWire); + ? msgs.getGroupLatest(conv, limit).then((rows) => rows.map(groupMsgToWire)) + : msgs.getC2cLatest(conv, limit).then((rows) => rows.map(c2cMsgToWire)); return withRoamingHistory(services, kind, conv, rows, limit); } @@ -735,8 +751,8 @@ async function fetchBefore( const msgs = services.msgs; const rows = kind === 'group' - ? (await msgs.getGroupBefore(conv, beforeSeq, limit)).map(groupMsgToWire) - : (await msgs.getC2cBefore(conv, beforeSeq, limit)).map(c2cMsgToWire); + ? msgs.getGroupBefore(conv, beforeSeq, limit).then((rows) => rows.map(groupMsgToWire)) + : msgs.getC2cBefore(conv, beforeSeq, limit).then((rows) => rows.map(c2cMsgToWire)); return withRoamingHistory(services, kind, conv, rows, limit, 1n, beforeSeq - 1n); } @@ -750,8 +766,8 @@ async function fetchAfter( const msgs = services.msgs; const rows = kind === 'group' - ? (await msgs.getGroupAfter(conv, afterSeq, limit)).map(groupMsgToWire) - : (await msgs.getC2cAfter(conv, afterSeq, limit)).map(c2cMsgToWire); + ? msgs.getGroupAfter(conv, afterSeq, limit).then((rows) => rows.map(groupMsgToWire)) + : msgs.getC2cAfter(conv, afterSeq, limit).then((rows) => rows.map(c2cMsgToWire)); return withRoamingHistory(services, kind, conv, rows, limit, afterSeq + 1n, 0xffffffffn, false); } @@ -765,8 +781,8 @@ async function fetchFrom( const msgs = services.msgs; const rows = kind === 'group' - ? (await msgs.getGroupFrom(conv, sinceSeq, limit)).map(groupMsgToWire) - : (await msgs.getC2cFrom(conv, sinceSeq, limit)).map(c2cMsgToWire); + ? msgs.getGroupFrom(conv, sinceSeq, limit).then((rows) => rows.map(groupMsgToWire)) + : msgs.getC2cFrom(conv, sinceSeq, limit).then((rows) => rows.map(c2cMsgToWire)); return withRoamingHistory(services, kind, conv, rows, limit, sinceSeq); } @@ -776,9 +792,12 @@ function albumAccessState(services = requireServices()): GroupAlbumAccessState { const expiresAt = record?.clientKey ? clientKeyExpiryMs(record.clientKey) : null; const secondsLeft = expiresAt ? Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)) : 0; return { + source: record?.configId + ? { expectedAccountUin: String(record.uin), expectedAccountConfigId: record.configId } + : undefined, qqOnline: Boolean(record?.qqOnline && record.qqPid), qqPid: record?.qqPid ?? null, - attachEnabled: getAppContext().bootstrap?.userConfig.getSettings().autoAttachQq ?? true, + attachEnabled: fullModeAllowed(getAppContext()), hasA2: Boolean(session?.a2), hasD2: Boolean(session?.d2), hasD2Key: Boolean(session?.d2Key), @@ -1631,6 +1650,7 @@ function unreadInfoToWire( unreadCount: row.unreadCount, readSeq: row.readSeq, readError: row.readError ?? false, + countPending: row.countPending ?? false, msgSeq: row.msgSeq?.toString(), highlights: row.highlights?.map((h) => ({ kind: h.kind, @@ -1643,7 +1663,35 @@ function unreadInfoToWire( }; } +const browserFileBinding = z.object({ + expectedAccountUin: z.string().min(1), + expectedAccountConfigId: z.string().min(1), +}); +const browserFiles = new FileUploadStaging(30 * 60000, { + label: '附件', + minBytes: 1, + maxBytes: BROWSER_FILE_MAX_BYTES, + prefix: 'weq-browser-file-', + fileName: 'attachment', +}); + export const accountRouter = router({ + /** 固定字段的性能快照:不包含消息正文、会话 ID、SQL、路径或凭据。 */ + messagePerformance: procedure.input(exportAccountBindingInput).query(({ input }) => { + requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId); + const session = getAppContext().account!; + return { + runtime: { + implementation: 'message-scheduler-v1', + pid: process.pid, + node: process.versions.node, + electron: process.versions.electron ?? null, + nativeBuild: null, + nativeQueueObservable: false, + }, + database: databasePerformance(session.msgDbPath), + }; + }), qqActions: createQQActionsRouter( (input) => { const services = requireExpectedAccount( @@ -1737,13 +1785,13 @@ export const accountRouter = router({ }), getAgentLabPersona: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .query(({ input }) => { return requireServices().agentLab.getPersona(input.personaId); }), getAgentLabPersonaDetail: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .query(({ input }) => { return requireServices().agentLab.getPersonaDetail(input.personaId); }), @@ -1751,7 +1799,7 @@ export const accountRouter = router({ buildAgentLabFromC2c: procedure .input( z.object({ - personaId: z.string().min(1), + personaId: agentLabPersonaId, name: z.string().optional(), models: agentLabModels, customPrompt: z.string().optional(), @@ -1779,6 +1827,12 @@ export const accountRouter = router({ }); }), + refreshAgentLabFromC2c: procedure + .input(z.object({ personaId: agentLabPersonaId, expectedAccountUin: z.string().min(1) })) + .mutation(({ input }) => + requireExpectedAccount(input.expectedAccountUin).agentLab.refreshFromC2c(input.personaId), + ), + /** Subscribe to AgentLab clone-build progress (drives the build progress bar). */ onAgentLabBuildProgress: procedure.subscription(() => { return observable((emit) => { @@ -1794,7 +1848,7 @@ export const accountRouter = router({ chatWithAgentLabPersona: procedure .input( z.object({ - personaId: z.string().min(1), + personaId: agentLabPersonaId, text: z.string().min(1), sessionId: z.string().optional(), history: z @@ -1818,21 +1872,21 @@ export const accountRouter = router({ /** 克隆体会话(好友克隆多会话)列表,最近活跃倒序。 */ listAgentLabPersonaSessions: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .query(({ input }) => { return requireServices().agentLab.listPersonaSessions(input.personaId); }), /** 新建一个克隆体会话(空对话,标题待首条消息生成)。 */ createAgentLabPersonaSession: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .mutation(({ input }) => { return requireServices().agentLab.createPersonaSession(input.personaId); }), /** 删除克隆体会话(含其对话内容)。 */ deleteAgentLabPersonaSession: procedure - .input(z.object({ personaId: z.string().min(1), sessionId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId, sessionId: z.string().min(1) })) .mutation(({ input }) => { requireServices().agentLab.deletePersonaSession(input.personaId, input.sessionId); return true; @@ -1840,7 +1894,7 @@ export const accountRouter = router({ /** 某克隆体会话的对话历史。 */ getAgentLabPersonaSessionConversation: procedure - .input(z.object({ personaId: z.string().min(1), sessionId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId, sessionId: z.string().min(1) })) .query(({ input }) => { return requireServices().agentLab.getPersonaSessionConversation( input.personaId, @@ -1878,7 +1932,7 @@ export const accountRouter = router({ }), deleteAgentLabPersona: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .mutation(({ input }) => { return requireServices().agentLab.deletePersona(input.personaId); }), @@ -1887,7 +1941,7 @@ export const accountRouter = router({ exportAgentLabPersona: procedure .input( z.object({ - personaId: z.string().min(1), + personaId: agentLabPersonaId, adapterType: z.enum(['napcat', 'snowluma']), wsUrl: z.string().min(1), token: z.string().optional(), @@ -1908,15 +1962,28 @@ export const accountRouter = router({ const record = svc.getPersonaRecord(input.personaId); if (!record) throw new Error('找不到克隆体'); const { persona } = record; + assertEmbeddingModelIdentity( + persona, + persona.models.embedding + ? cfg.resolveEndpoint(persona.models.embedding, 'embedding') + : null, + ); + + // 独立 bot 不读取桌面端全局设置,导出时固定实际模型及其 provider。 + const models = cfg.resolveModels(persona.models); + const visionModel = input.visionModel + ? cfg.resolveModelRef(input.visionModel, 'vision') + : undefined; // 抽 persona 用到的 LLM providers(chat/embedding/vision,去重)。 // 导出弹窗显式指定的图像模型也并进来(即使克隆时没用 vision,也能让 bot 具备解析新表情的能力)。 const llmIds = new Set(); for (const m of [ - persona.models.chat, - persona.models.embedding, - persona.models.vision, - input.visionModel, + models.chat, + models.embedding, + models.vision, + models.voiceClone, + visionModel, ]) { if (m?.providerId) llmIds.add(m.providerId); } @@ -1947,7 +2014,7 @@ export const accountRouter = router({ const result = await buildBotExport({ outDir, botRuntimeMjs: botMjs, - persona, + persona: { ...persona, models }, pairs: record.pairs, agentlabRoot: svc.assetRoot, llmProviders, @@ -1960,7 +2027,7 @@ export const accountRouter = router({ groupReplyMode: input.groupReplyMode ?? 'llm', }, webuiPort: input.webuiPort, - visionModel: input.visionModel, + visionModel, }); // 记录 id → WebUI 访问信息,导出后在设置页可查密钥 / 一键打开控制台。 svc.recordExport(input.personaId, { @@ -1976,7 +2043,7 @@ export const accountRouter = router({ /** 查某克隆体最近一次导出的 WebUI 访问信息(密钥/端口/url;没导出过则 null)。 */ getAgentLabExportInfo: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .query(({ input }) => { return requireServices().agentLab.getExportInfo(input.personaId) ?? null; }), @@ -1986,7 +2053,7 @@ export const accountRouter = router({ * 先探活默认地址(或用户传入的 url);不可达则返回 { needUrl: true } 让前端提示输入地址。 */ openBotWebUi: procedure - .input(z.object({ personaId: z.string().min(1), url: z.string().optional() })) + .input(z.object({ personaId: agentLabPersonaId, url: z.string().optional() })) .mutation(async ({ input }) => { const svc = requireServices().agentLab; const info = svc.getExportInfo(input.personaId); @@ -2011,7 +2078,7 @@ export const accountRouter = router({ // ── 克隆体群聊(M2 群骨架)───────────────────────────────────────────── createAgentLabGroup: procedure - .input(z.object({ name: z.string().min(1), personaIds: z.array(z.string().min(1)).min(1) })) + .input(z.object({ name: z.string().min(1), personaIds: z.array(agentLabPersonaId).min(1) })) .mutation(({ input }) => { return requireServices().agentLab.createGroup({ name: input.name, @@ -2081,7 +2148,7 @@ export const accountRouter = router({ }), addAgentLabGroupMember: procedure - .input(z.object({ groupId: z.string().min(1), personaId: z.string().min(1) })) + .input(z.object({ groupId: z.string().min(1), personaId: agentLabPersonaId })) .mutation(({ input }) => { requireServices().agentLab.addGroupMember(input.groupId, input.personaId); return true; @@ -2167,7 +2234,7 @@ export const accountRouter = router({ updateAgentLabPersona: procedure .input( z.object({ - personaId: z.string().min(1), + personaId: agentLabPersonaId, chatModel: agentLabModelRef.optional(), generation: z .object({ @@ -2232,13 +2299,13 @@ export const accountRouter = router({ /** 与某克隆体的持久化对话历史。 */ getAgentLabConversation: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .query(({ input }) => { return requireServices().agentLab.getConversation(input.personaId); }), clearAgentLabConversation: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .mutation(({ input }) => { requireServices().agentLab.clearConversation(input.personaId); return true; @@ -2246,20 +2313,20 @@ export const accountRouter = router({ /** 克隆体对「对方」的记忆(灯箱「记忆 / 画像」用)。 */ getAgentLabMemories: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .query(({ input }) => { return requireServices().agentLab.getMemories(input.personaId); }), forgetAgentLabMemory: procedure - .input(z.object({ personaId: z.string().min(1), memoryId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId, memoryId: z.string().min(1) })) .mutation(({ input }) => { requireServices().agentLab.forgetMemory(input.personaId, input.memoryId); return true; }), clearAgentLabMemories: procedure - .input(z.object({ personaId: z.string().min(1) })) + .input(z.object({ personaId: agentLabPersonaId })) .mutation(({ input }) => { requireServices().agentLab.clearMemories(input.personaId); return true; @@ -2277,6 +2344,8 @@ export const accountRouter = router({ customPrompt: z.string().optional(), reasoningEffort: z.enum(['off', 'low', 'medium', 'high']).optional(), mcpServers: z.string().optional(), + permissionMode: z.enum(['sandbox', 'unrestricted']).optional(), + confirmUnrestricted: z.boolean().optional(), }), ) .mutation(({ input }) => { @@ -2288,6 +2357,8 @@ export const accountRouter = router({ customPrompt: input.customPrompt, reasoningEffort: input.reasoningEffort, mcpServers: input.mcpServers, + permissionMode: input.permissionMode, + confirmUnrestricted: input.confirmUnrestricted, }); }), @@ -2691,7 +2762,12 @@ export const accountRouter = router({ /** 首页门面:随机一言若干(打字机轮播用;已按句长筛过)。 */ sampleHitokoto: procedure .input(z.object({ count: z.number().int().min(1).max(80).default(30) }).optional()) - .query(({ input }) => sampleHitokoto(input?.count ?? 30)), + .query(({ input }) => + sampleHitokoto( + input?.count ?? 30, + getAppContext().bootstrap?.userConfig.getSettings().home.blockedWords, + ), + ), /** 首页个性装扮快照(从账号 config 读,bootstrap 阶段在线注入后写入)。 */ getHomeDress: procedure.query(() => { @@ -2708,6 +2784,7 @@ export const accountRouter = router({ z.object({ expectedAccountUin: z.string().min(1), expectedAccountConfigId: z.string().min(1).optional(), + watermarksOnly: z.boolean().optional(), targets: z .array( z.object({ @@ -2723,7 +2800,7 @@ export const accountRouter = router({ input.expectedAccountUin, input.expectedAccountConfigId, ); - const rows = await services.unreadInfo.getUnreadInfos(input.targets); + const rows = await services.unreadInfo.getUnreadInfos(input.targets, input.watermarksOnly); if ( requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId) !== services ) @@ -3912,6 +3989,7 @@ export const accountRouter = router({ keyword: z.string().trim().min(1), limit: z.number().int().min(1).max(20).default(3), categories: z.array(z.enum(SEARCH_CATEGORIES)).optional(), + conversationScope: z.enum(['all', 'real', 'virtual', 'guild']).default('all'), }), ) .query(async ({ input }) => { @@ -3919,6 +3997,7 @@ export const accountRouter = router({ input.keyword, input.limit, input.categories, + input.conversationScope, ); }), @@ -3929,6 +4008,7 @@ export const accountRouter = router({ keyword: z.string().trim().min(1), limit: z.number().int().min(1).max(20).default(3), categories: z.array(z.enum(SEARCH_CATEGORIES)).optional(), + conversationScope: z.enum(['all', 'real', 'virtual', 'guild']).default('all'), }), ) .query(async ({ input }) => { @@ -3936,6 +4016,7 @@ export const accountRouter = router({ input.keyword, input.limit, input.categories, + input.conversationScope, ); }), @@ -3954,6 +4035,7 @@ export const accountRouter = router({ .input( z.object({ category: z.enum(SEARCH_CATEGORIES), + conversationScope: z.enum(['all', 'real', 'virtual', 'guild']).default('all'), keyword: z.string().trim().min(1), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(100).default(20), @@ -3965,6 +4047,7 @@ export const accountRouter = router({ input.keyword, input.offset, input.limit, + input.conversationScope, ); }), @@ -4191,11 +4274,17 @@ export const accountRouter = router({ recalledMessages: procedure .input(z.object({ kind: z.enum(['c2c', 'group']), conv: z.string().min(1) })) .query(async ({ input }): Promise => { - const msgs = requireServices().msgs; + const services = requireServices(); + const msgs = services.msgs; const rows = await msgs.getRecalledMessages(input.kind, input.conv); - return input.kind === 'group' - ? (rows as RenderGroupMsg[]).map(groupMsgToWire) - : (rows as RenderC2cMsg[]).map(c2cMsgToWire); + const wire = + input.kind === 'group' + ? (rows as RenderGroupMsg[]).map(groupMsgToWire) + : (rows as RenderC2cMsg[]).map(c2cMsgToWire); + const cached = await services.gapHistory.cachedRecalls(input.kind, input.conv); + return mergeRoamingTimeline(input.kind, wire, cached.map(roamingMsgToWire)).sort( + (a, b) => (b.recall?.recallTs ?? 0) - (a.recall?.recallTs ?? 0), + ); }), /** @@ -4501,7 +4590,7 @@ export const accountRouter = router({ * 撤回一条消息(SsoGroupRecallMsg / SsoC2CRecallMsg)。 * * 权限(2 分钟窗口 / 群主管理员越级)由服务端判定,这里只要求在线已注入的 QQ。 - * **不做任何本地写库** —— 防撤回触发器等 QQ 同步回来自证,前端只看 toast。 + * 不修改 QQ 原库;受保护的 WeQ 发送缓存等服务端确认成功后单独保留原文。 */ recallMessage: procedure .input( @@ -4522,14 +4611,39 @@ export const accountRouter = router({ input.expectedAccountConfigId, ); requireQqOnlineForAlbum(services); - await services.groupModeration.recallMessage({ + const params = { kind: input.kind, conv: input.conv, sequence: input.sequence, ...(input.random !== undefined ? { random: input.random } : {}), ...(input.timestamp !== undefined ? { timestamp: input.timestamp } : {}), ...(input.clientSequence !== undefined ? { clientSequence: input.clientSequence } : {}), - }); + }; + const session = getAppContext().account; + const send = () => { + if (getAppContext().services !== services || getAppContext().account !== session) { + throw new Error('账号或数据源已切换,已停止撤回'); + } + return services.groupModeration.recallMessage(params); + }; + if (services.antiRecall.protects(input.kind, input.conv)) { + const recalled = await services.gapHistory.withSelfRecallProtection(params, send); + if ( + recalled && + session && + getAppContext().services === services && + getAppContext().account === session + ) { + dbEventBus.emit('roaming', { + session, + kind: input.kind, + conv: input.conv, + messages: [recalled], + } satisfies RoamingCacheChange); + } + } else { + await send(); + } return { ok: true }; }), @@ -4695,6 +4809,39 @@ export const accountRouter = router({ }); }), + /** 本地未读差分已落地后立即通知目标,避免红点依赖全会话扫描。 */ + onUnreadChanged: procedure.input(exportAccountBindingInput).subscription(({ input }) => { + const services = requireExpectedAccount( + input.expectedAccountUin, + input.expectedAccountConfigId, + ); + const uin = String(getAppContext().account?.context.uin ?? ''); + const configId = services.accountConfig.getRecord()?.configId; + return observable>((emit) => { + const handler = (change: NewMessages): void => { + const ctx = getAppContext(); + if ( + ctx.services !== services || + String(ctx.account?.context.uin ?? '') !== uin || + services.accountConfig.getRecord()?.configId !== configId + ) + return; + const targets = [ + ...change.c2c.map((msg) => ({ chatType: 1, uid: msg.targetUid })), + ...change.group.map((msg) => ({ chatType: 2, uid: msg.targetGroupCode })), + ...(change.dataline ?? []).map((msg) => ({ chatType: msg.chatType, uid: msg.targetUid })), + ]; + emit.next([ + ...new Map( + targets.map((target) => [`${target.chatType}:${target.uid}`, target]), + ).values(), + ]); + }; + dbEventBus.on('new', handler); + return () => dbEventBus.off('new', handler); + }); + }), + /** * Live push of newly-inserted messages (rowid-delta). Reserved for unread / * popup notifications — the open conversation is kept fresh by `onDbChanged`, @@ -4719,15 +4866,33 @@ export const accountRouter = router({ }), /** Live prerequisites for group album list/media/export. */ - getGroupAlbumAccessState: procedure.query(() => { - const record = requireServices().accountConfig.getRecord(); + getGroupAlbumAccessState: procedure.query(async () => { + const services = requireServices(); + const record = services.accountConfig.getRecord(); const session = record?.session; - return { - ...albumAccessState(), - needsMemoryRead: Boolean( + const access = albumAccessState(services); + const needsMemoryRead = Boolean( + record?.qqPid && (session?.pid !== record.qqPid || !isCompleteSessionMaterial(session)), + ); + // 只查询原生登记表,不读内存或发包;配置写盘可能早于原生登记完成。 + const ssoReady = Boolean( + access.qqOnline && + access.attachEnabled && + !needsMemoryRead && record?.qqPid && - (session?.pid !== record.qqPid || !session?.a2 || !session?.d2 || !session?.d2Key), - ), + record.uid && + record.guid && + (await getAppContext().platform?.native.ntHelper.hasSsoSession(record.qqPid)), + ); + if (getAppContext().services !== services) throw new Error('账号已切换,请重新检查在线状态。'); + const attachEnabled = fullModeAllowed(getAppContext()); + return { + ...access, + attachEnabled, + hasUid: Boolean(record?.uid), + needsMemoryRead, + ssoReady: attachEnabled && ssoReady, + credentialsPending: getAppContext().onlineCredentialsPending(), }; }), @@ -5015,24 +5180,10 @@ export const accountRouter = router({ return { ok: true }; }), - /** - * 私聊「窗口抖动」—— 一条独立的消息,不跟正文 / 引用一起发。 - * - * 走 `MessageSvc.PbSendMsg` 的 `poke` 元素(`commonElem serviceType=2`), - * 见 `MessageSendService.sendWindowShake`。**群聊没有这个能力**(服务端只认私聊), - * 所以这里不收 peerType,目标一律按私聊解析。 - */ - sendWindowShake: procedure - .input(z.object({ targetId: z.string().min(1) })) - .mutation(async ({ input }) => { - requireQqOnlineForAlbum(); - return requireServices().messageSend.sendWindowShake({ targetId: input.targetId }); - }), - /** * 发消息主链路:一条消息的元素数组。 * - * 文本 / @ / 表情 / 超级表情 / 商城表情 / 引用 / 窗口抖动 / 弹射表情,以及 + * 文本 / @ / 表情 / 超级表情 / 商城表情 / 引用 / 互动表情 / 弹射表情,以及 * **图片 / 语音 / 视频**(媒体元素在服务层真实上传 NTV2)都走这里。 * 元素形状见 @weq/service 的 `SendElement`:媒体元素的 `source` 是本机绝对路径 * (优先)或字节(剪贴板等无路径来源)。字节以 `{ type:'Buffer', data }` 过 IPC, @@ -5041,6 +5192,56 @@ export const accountRouter = router({ * `tempGroupCode` 只在「群里发起的临时会话」里给(会话行的 60001 来源群号):给了 * 就走 `routingHead.grpTmp` 而不是 c2c —— 见 `MessageSendService` 的 `GroupTempSource`。 */ + prepareChatFont: procedure + .input( + z.object({ + peerType: z.enum(['c2c', 'group']), + targetId: z.string().min(1), + tempGroupCode: z.string().min(1).optional(), + expectedAccountUin: z.string().min(1), + expectedAccountConfigId: z.string().min(1).optional(), + }), + ) + .query(async ({ input }) => { + const services = requireExpectedAccount( + input.expectedAccountUin, + input.expectedAccountConfigId, + ); + requireQqOnlineForAlbum(services); + const font = await services.messageSend.prepareChatFont(input); + if ( + requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId) !== services + ) + throw new Error('账号数据源已切换,已停止发送'); + return font; + }), + + /** 回读前的装扮快照;字体与发送请求共用原值,气泡先使用账号配置。 */ + prepareChatDecoration: procedure + .input( + z.object({ + peerType: z.enum(['c2c', 'group']), + targetId: z.string().min(1), + tempGroupCode: z.string().min(1).optional(), + expectedAccountUin: z.string().min(1), + expectedAccountConfigId: z.string().min(1).optional(), + hasText: z.boolean(), + }), + ) + .query(async ({ input }) => { + const services = requireExpectedAccount( + input.expectedAccountUin, + input.expectedAccountConfigId, + ); + requireQqOnlineForAlbum(services); + const decoration = await services.messageSend.prepareChatDecoration(input); + if ( + requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId) !== services + ) + throw new Error('账号数据源已切换,已停止发送'); + return decoration; + }), + sendElements: procedure .input( z.object({ @@ -5051,6 +5252,17 @@ export const accountRouter = router({ tempGroupCode: z.string().min(1).optional(), elements: z.array(z.any()).min(1), dress: z.any().optional(), + preparedChatFont: z + .object({ + fontId: z.number().int().positive().max(0xffff), + fontId2Raw: z.number().int().positive().max(0xffff_ffff), + }) + .refine( + (font) => + font.fontId === (((font.fontId2Raw & 0xff) << 8) | ((font.fontId2Raw >>> 8) & 0xff)), + ) + .nullable() + .optional(), }), ) .mutation(async ({ input }) => { @@ -5083,26 +5295,73 @@ export const accountRouter = router({ targetId: input.targetId, ...(input.tempGroupCode ? { tempGroupCode: input.tempGroupCode } : {}), elements: elements as never, + ...(input.preparedChatFont !== undefined + ? { preparedChatFont: input.preparedChatFont } + : {}), ...(input.dress ? { dress: input.dress as never } : {}), }); }), - /** - * 发文件(群文件 / 私聊文件)。文件走的是独立管线(老 OIDB + highway 裸帧), - * 不是媒体元素,所以单开一条 route —— 渲染层给本机**绝对路径**(`webUtils.getPathForFile`)。 - */ - sendFile: procedure + browserFileStart: procedure + .input(browserFileBinding.extend({ size: z.number().int().min(1).max(BROWSER_FILE_MAX_BYTES) })) + .mutation(({ input }) => { + const services = requireExpectedAccount( + input.expectedAccountUin, + input.expectedAccountConfigId, + ); + requireQqOnlineForAlbum(services); + return browserFiles.start(services, input.size); + }), + browserFileChunk: procedure .input( - z.object({ - peerType: z.enum(['c2c', 'group']), - targetId: z.string().min(1), - expectedAccountUin: z.string().min(1).optional(), - expectedAccountConfigId: z.string().min(1).optional(), - path: z.string().min(1), - fileName: z.string().min(1).optional(), - folderId: z.string().optional(), + browserFileBinding.extend({ + uploadId: z.string().uuid(), + offset: z.number().int().nonnegative(), + base64: z + .string() + .min(1) + .max(Math.ceil(BROWSER_FILE_CHUNK_BYTES / 3) * 4), }), ) + .mutation(({ input }) => { + const services = requireExpectedAccount( + input.expectedAccountUin, + input.expectedAccountConfigId, + ); + const bytes = Buffer.from(input.base64, 'base64'); + if (bytes.toString('base64') !== input.base64) throw new Error('附件分段编码无效'); + return browserFiles.append(services, input.uploadId, input.offset, bytes); + }), + browserFileDiscard: procedure + .input(browserFileBinding.extend({ uploadId: z.string().uuid() })) + .mutation(({ input }) => + browserFiles.discard( + requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId), + input.uploadId, + ), + ), + + /** 桌面文件走原路径;浏览器附件使用已完整接收、绑定当前账号的暂存句柄。 */ + sendFile: procedure + .input( + z + .object({ + peerType: z.enum(['c2c', 'group']), + targetId: z.string().min(1), + expectedAccountUin: z.string().min(1).optional(), + expectedAccountConfigId: z.string().min(1).optional(), + path: z.string().min(1).optional(), + uploadId: z.string().uuid().optional(), + fileName: z.string().min(1).optional(), + folderId: z.string().optional(), + }) + .refine((input) => Boolean(input.path) !== Boolean(input.uploadId), '请选择一个文件来源') + .refine( + (input) => + !input.uploadId || Boolean(input.expectedAccountUin && input.expectedAccountConfigId), + '浏览器附件必须绑定账号', + ), + ) .mutation(async ({ input }) => { const services = requireExpectedAccount( input.expectedAccountUin, @@ -5111,23 +5370,28 @@ export const accountRouter = router({ requireQqOnlineForAlbum(services); const sourceConfigId = services.accountConfig.getRecord()?.configId; const sourcePid = services.accountConfig.getRecord()?.qqPid; - return services.messageSend.sendFile({ - peerType: input.peerType, - targetId: input.targetId, - path: input.path, - ...(input.fileName ? { fileName: input.fileName } : {}), - ...(input.folderId ? { folderId: input.folderId } : {}), - beforeSend: () => { - if ( - requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId) !== - services || - services.accountConfig.getRecord()?.configId !== sourceConfigId || - services.accountConfig.getRecord()?.qqPid !== sourcePid - ) - throw new Error('账号数据源已切换,已停止发送文件'); - requireQqOnlineForAlbum(services); - }, - }); + const path = input.uploadId ? browserFiles.acquire(services, input.uploadId) : input.path!; + try { + return await services.messageSend.sendFile({ + peerType: input.peerType, + targetId: input.targetId, + path, + ...(input.fileName ? { fileName: input.fileName } : {}), + ...(input.folderId ? { folderId: input.folderId } : {}), + beforeSend: () => { + if ( + requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId) !== + services || + services.accountConfig.getRecord()?.configId !== sourceConfigId || + services.accountConfig.getRecord()?.qqPid !== sourcePid + ) + throw new Error('账号数据源已切换,已停止发送文件'); + requireQqOnlineForAlbum(services); + }, + }); + } finally { + if (input.uploadId) await browserFiles.discard(services, input.uploadId, true); + } }), /** @@ -5474,6 +5738,121 @@ export const accountRouter = router({ }), ), + // ---- 位置共享(QQLBSShareSvc) ---- + + /** + * 发起一次位置共享(私聊 / 群聊同一套命令,只有会话键不同)。 + * + * 房间**不由服务端分配** —— 私聊的房间就是「这两个人」,群聊的就是「这个群」。 + * 发起后必须按 {@link locationShareReport} 的响应间隔持续上报,否则约 60 秒后 + * 服务端会把房间标记为已结束。没有「退出共享」命令:停上报即可。 + */ + locationShareStart: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + /** 私聊:对端 QQ 号。群聊:群号。 */ + peer: z.number().int().safe().positive(), + /** 自己的 QQ 号(私聊会话键要用;上报也需要)。 */ + selfUin: z.number().int().safe().positive(), + longitude: z.number(), + latitude: z.number(), + interval: z.number().int().min(1).max(60).optional(), + }), + ) + .mutation(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.start({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + longitude: input.longitude, + latitude: input.latitude, + ...(input.interval !== undefined ? { interval: input.interval } : {}), + }); + }), + + /** 上报一次自己的坐标;响应里的 `interval` 是服务端要求的下次间隔(秒)。 */ + locationShareReport: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + peer: z.number().int().safe().positive(), + selfUin: z.number().int().safe().positive(), + longitude: z.number(), + latitude: z.number(), + extra: z.number().optional(), + }), + ) + .mutation(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.report({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + longitude: input.longitude, + latitude: input.latitude, + ...(input.extra !== undefined ? { extra: input.extra } : {}), + }); + }), + + /** + * 查询房间里所有成员的坐标 —— **唯一**能拿到别人位置的途径(位置不会推送)。 + * + * `status === 2` 表示房间已结束(停上报超时),此时 `members` 为空。 + */ + locationShareQuery: procedure + .input( + z.object({ + scene: z.enum(['c2c', 'group']), + peer: z.number().int().safe().positive(), + selfUin: z.number().int().safe().positive(), + mode: z.union([z.literal(0), z.literal(1)]).optional(), + }), + ) + .query(async ({ input }) => { + requireQqOnlineForAlbum(); + return requireServices().locationShare.query({ + scene: input.scene, + peer: input.peer, + selfUin: input.selfUin, + ...(input.mode !== undefined ? { mode: input.mode } : {}), + }); + }), + + /** + * 发「发起了位置共享」消息卡片。 + * + * 真实客户端由服务端推送这条消息;WeQ 的 SSO 是「发一包就断」,服务端判定发起方 + * 不在线就不投递 —— 所以自己发。 + * + * **元素必须在主进程构造**:`buildShareLocationElements` 在 `@weq/protocol` 里, + * 而渲染层不能引那个包(远程 Web 端复用渲染层代码,protocol 里有 `node:crypto` + * 的红包签名,浏览器端打不出来)。所以这里收「场景 + 目标」,元素自己拼。 + */ + sendLocationShareCard: procedure + .input( + z.object({ + expectedAccountUin: z.string().min(1).optional(), + expectedAccountConfigId: z.string().min(1).optional(), + scene: z.enum(['c2c', 'group']), + /** 私聊:对端 QQ 号。群聊:群号。 */ + targetId: z.string().min(1), + }), + ) + .mutation(async ({ input }) => { + const services = requireExpectedAccount( + input.expectedAccountUin, + input.expectedAccountConfigId, + ); + requireQqOnlineForAlbum(services); + return services.messageSend.sendElements({ + peerType: input.scene === 'group' ? 'group' : 'c2c', + targetId: input.targetId, + elements: buildShareLocationElements(input.scene) as never, + }); + }), + // ---- database decrypt ---- /** List encrypted `*.db` files under the open account's nt_db directory. */ @@ -5616,7 +5995,7 @@ export const accountRouter = router({ // 没有在线 QQ(或处于完全离线模式)就没有 clientKey 可换 —— 退回裸地址, // 用户自己在浏览器里登录。 - const offlineMode = ctx.bootstrap?.userConfig.getSettings().autoAttachQq === false; + const offlineMode = !fullModeAllowed(ctx); if (!uin || !nt || !record?.qqOnline || !record.qqPid || offlineMode) { return { url: landing, autoLogin: false }; } @@ -5649,7 +6028,7 @@ export const accountRouter = router({ const uin = ctx.account?.context.uin; const nt = ctx.platform?.native.ntHelper; const record = services.accountConfig.getRecord(); - const offlineMode = ctx.bootstrap?.userConfig.getSettings().autoAttachQq === false; + const offlineMode = !fullModeAllowed(ctx); if (!uin || !nt || !record?.qqOnline || !record.qqPid || offlineMode) { if (offlineMode) { return { @@ -6209,8 +6588,7 @@ export const accountRouter = router({ const offset = input?.offset ?? 0; // 完全离线模式(自动读取 QQ 内存 关闭):网络同步需要 weiyun p_skey(在线实例), // 一律回退本地 collection.db,避免白打一次网络 + 凭证换取。 - const offlineMode = - getAppContext().bootstrap?.userConfig.getSettings().autoAttachQq === false; + const offlineMode = !fullModeAllowed(getAppContext()); const source = offlineMode ? 'db' : (input?.source ?? 'auto'); const page = source === 'db' @@ -6656,9 +7034,36 @@ export const accountRouter = router({ * explicit "立即重新获取 rkey" before a media-completing export. Returns true * when fresh rkeys were stored. 完全离线模式(自动读取 QQ 内存 关闭)下直接返回 false。 */ - refreshRkeys: procedure.mutation(() => { - return getAppContext().refreshRkeysNow(); - }), + requestFullMode: procedure + .input( + z + .object({ + expectedAccountUin: z.string().min(1), + expectedAccountConfigId: z.string().min(1), + }) + .strict(), + ) + .mutation(({ input }) => { + requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId); + const host = getAppContext(); + if (!host.account) throw new Error('当前未打开账号'); + requestFullMode(host, host.account); + return { requested: true }; + }), + + refreshRkeys: procedure + .input(exportAccountBindingInput.strict().optional()) + .mutation(async ({ input }) => { + const context = getAppContext(); + const services = input + ? requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId) + : context.services; + const session = context.account; + const result = await context.refreshRkeysNow(); + if (getAppContext().services !== services || getAppContext().account !== session) + throw new Error('账号来源已切换,已丢弃旧凭据刷新结果'); + return result; + }), /** List all export tasks. */ exportPreflight: procedure @@ -6729,6 +7134,7 @@ export const accountRouter = router({ ) throw new Error('账号数据源已切换,已取消保存。'); }, + getAppContext().bootstrap?.userConfig.getSettings(), ); }), @@ -6855,6 +7261,22 @@ export const accountRouter = router({ return requireServices().exportManager.cancelTask(input.taskId); }), + /** 清除确认范围内的已完成任务,绑定账号及配置来源。 */ + clearCompletedTasks: procedure + .input( + generatedAccountInput.extend({ + exports: z.array(z.string().min(1)), + roaming: z.array(z.string().uuid()), + flash: z.array(z.string().min(1)), + }), + ) + .mutation(({ input }) => + clearCompletedTasks( + requireExpectedAccount(input.expectedAccountUin, input.expectedAccountConfigId), + input, + ), + ), + /** Delete a task. */ deleteExportTask: procedure .input(z.object({ taskId: z.string().min(1) })) diff --git a/apps/desktop/src/main/ipc/routers/album_video_staging.ts b/apps/desktop/src/main/ipc/routers/album_video_staging.ts index a200e09d2..b93f4adb9 100644 --- a/apps/desktop/src/main/ipc/routers/album_video_staging.ts +++ b/apps/desktop/src/main/ipc/routers/album_video_staging.ts @@ -12,11 +12,20 @@ interface Upload { expires: number; busy: boolean; } -/** Web 本地文件分段暂存;大小只受实际磁盘空间约束,不把整个视频放进内存。 */ -export class AlbumVideoStaging { +/** Web 本地文件分段暂存;保留调用方大小限制并逐段核实磁盘空间。 */ +export class FileUploadStaging { private uploads = new Map(); private timer: ReturnType | null = null; - constructor(private readonly ttlMs = 30 * 60000) {} + constructor( + private readonly ttlMs = 30 * 60000, + private readonly options = { + label: '视频', + minBytes: 16, + maxBytes: Number.MAX_SAFE_INTEGER, + prefix: 'weq-album-video-', + fileName: 'video.mp4', + }, + ) {} private schedule() { if (this.timer) clearTimeout(this.timer); this.timer = null; @@ -35,14 +44,15 @@ export class AlbumVideoStaging { this.timer.unref?.(); } async start(owner: object, size: number) { - if (!Number.isSafeInteger(size) || size < 16) throw new Error('视频文件大小无效'); + if (!Number.isSafeInteger(size) || size < this.options.minBytes || size > this.options.maxBytes) + throw new Error(`${this.options.label}文件大小无效`); await this.cleanup(); - if (this.uploads.size >= 16) throw new Error('请先完成或取消现有的视频上传'); + if (this.uploads.size >= 16) throw new Error(`请先完成或取消现有的${this.options.label}上传`); const space = await statfs(tmpdir(), { bigint: true }); if (BigInt(size) + 64n * 1024n * 1024n >= space.bavail * space.bsize) - throw new Error('宿主临时卷可用空间不足,无法暂存此视频'); - const directory = await mkdtemp(join(tmpdir(), 'weq-album-video-')); - const path = join(directory, 'video.mp4'); + throw new Error(`宿主临时卷可用空间不足,无法暂存${this.options.label}`); + const directory = await mkdtemp(join(tmpdir(), this.options.prefix)); + const path = join(directory, this.options.fileName); try { const handle = await open(path, 'wx', 0o600); await handle.close(); @@ -66,8 +76,8 @@ export class AlbumVideoStaging { private entry(owner: object, id: string) { const entry = this.uploads.get(id); if (!entry || entry.owner !== owner || entry.expires < Date.now()) - throw new Error('视频暂存会话已失效,请重新选择'); - if (entry.busy) throw new Error('视频暂存正在处理,请勿重复提交'); + throw new Error(`${this.options.label}暂存会话已失效,请重新选择`); + if (entry.busy) throw new Error(`${this.options.label}暂存正在处理,请勿重复提交`); return entry; } async append(owner: object, id: string, offset: number, bytes: Uint8Array) { @@ -78,7 +88,7 @@ export class AlbumVideoStaging { bytes.length > 1024 * 1024 || offset + bytes.length > entry.size ) - throw new Error('视频分段偏移或长度不匹配'); + throw new Error(`${this.options.label}分段偏移或长度不匹配`); entry.busy = true; try { const space = await statfs(entry.directory, { bigint: true }); @@ -94,7 +104,7 @@ export class AlbumVideoStaging { bytes.length - written, offset + written, ); - if (!result.bytesWritten) throw new Error('视频暂存写入中断'); + if (!result.bytesWritten) throw new Error(`${this.options.label}暂存写入中断`); written += result.bytesWritten; } } finally { @@ -110,7 +120,7 @@ export class AlbumVideoStaging { } acquire(owner: object, id: string) { const entry = this.entry(owner, id); - if (entry.offset !== entry.size) throw new Error('视频文件尚未传输完整'); + if (entry.offset !== entry.size) throw new Error(`${this.options.label}文件尚未传输完整`); entry.busy = true; this.schedule(); return entry.path; @@ -118,7 +128,7 @@ export class AlbumVideoStaging { async discard(owner: object, id: string, acquired = false) { const entry = this.uploads.get(id); if (!entry || entry.owner !== owner) return; - if (entry.busy && !acquired) throw new Error('视频正在上传,暂不能删除'); + if (entry.busy && !acquired) throw new Error(`${this.options.label}正在上传,暂不能删除`); entry.busy = true; try { await rm(entry.directory, { recursive: true, force: true }); @@ -134,3 +144,5 @@ export class AlbumVideoStaging { this.schedule(); } } + +export { FileUploadStaging as AlbumVideoStaging }; diff --git a/apps/desktop/src/main/ipc/routers/anti_recall.ts b/apps/desktop/src/main/ipc/routers/anti_recall.ts index 1e95f56df..16fc40a08 100644 --- a/apps/desktop/src/main/ipc/routers/anti_recall.ts +++ b/apps/desktop/src/main/ipc/routers/anti_recall.ts @@ -108,7 +108,32 @@ export const antiRecallRouter = router({ * 有撤回记录的会话目录(计数 + 最近一次撤回时间,最新活动在前)——「更多 → * 防撤回」里那一页按会话浏览撤回记录的入口。只读本地记录表,不依赖 QQ 在线。 */ - listRecallConversations: procedure.query(() => { - return requireServices().antiRecall.listRecallConversations(); + listRecallConversations: procedure.query(async () => { + const { antiRecall, gapHistory } = requireServices(); + const summaries = await antiRecall.listRecallConversations(); + const byConv = new Map(summaries.map((row) => [`${row.kind}:${row.conv}`, { ...row }])); + const knownSeqs = new Map>(); + for (const message of await gapHistory.cachedRecalls()) { + const key = `${message.kind}:${message.conv}`; + let seqs = knownSeqs.get(key); + if (!seqs) { + seqs = new Set( + (await antiRecall.listRecalls(message.kind, message.conv)).map((row) => row.origSeq), + ); + knownSeqs.set(key, seqs); + } + if (seqs.has(message.msgSeq)) continue; + seqs.add(message.msgSeq); + const summary = byConv.get(key) ?? { + kind: message.kind, + conv: message.conv, + count: 0, + lastTs: 0, + }; + summary.count++; + summary.lastTs = Math.max(summary.lastTs, message.recall?.recallTs ?? 0); + byConv.set(key, summary); + } + return [...byConv.values()].sort((a, b) => b.lastTs - a.lastTs); }), }); diff --git a/apps/desktop/src/main/ipc/routers/bootstrap.ts b/apps/desktop/src/main/ipc/routers/bootstrap.ts index e274b53ab..2c7e30a53 100644 --- a/apps/desktop/src/main/ipc/routers/bootstrap.ts +++ b/apps/desktop/src/main/ipc/routers/bootstrap.ts @@ -15,6 +15,7 @@ import { observable } from '@trpc/server/observable'; import { z } from 'zod'; +import { MAX_CHAT_IMAGE_CACHE_MIB } from '@weq/service/chat-image-cache'; import { isToastDuration } from '@weq/service/toast-settings'; import { copyExportSavePolicy, @@ -22,6 +23,7 @@ import { describeExportOutput, selectStorageCleanupTargets, type StorageRoot, + type AppAutostartStatus, createOtherDeviceKeyContext, } from '@weq/service'; import { @@ -35,27 +37,17 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { isMcpRunning } from '../../mcp/server'; -import { getDaemonHealth } from '../../daemon/health'; -import { resolveDaemonBinary } from '../../daemon/runtime'; -import { - daemonReleaseWatchStart, - daemonReleaseWatchStop, - daemonReleaseWatchStatus, - daemonAutostartSet, - daemonAutostartStatus, -} from '@weq/service'; +import { assistantResourceServer } from '../../weq_assistant/server'; import { installMcpAgents as installMcpAgentConfigs, listMcpAgentTargets, } from '../../mcp/agent_installer'; -import { daemonHttpStatus } from '@weq/service'; import { accountEventBus, getAppContext, requireBootstrap, requirePlatform, emitDressChanged, - getActiveIntentSenseLocalEngine, rememberAccountUid, type AccountForcedClosedEvent, } from '../../context/app_context'; @@ -63,6 +55,7 @@ import { runLogRetentionSweep } from '../../log_retention'; import { takePendingConversationJump, takePendingGroupJump } from '../../group_keyword_bus'; import { readNativeBuildInfo } from '@weq/native'; import { procedure, router } from '../trpc'; +import { fullModeAllowed } from '../../context/full_mode'; import { backupProcedures } from './backup'; import { accountConfigId, @@ -84,10 +77,16 @@ import { localOcrBlocker, } from '@weq/service'; import { runBackupProbe } from '../../backup_probe'; -import { discoverBackups, probeBackup, resolveBackupDirectory } from '../../static_backup'; +import { + scanBackupCandidates, + probeBackup, + resolveBackupDirectory, + type BackupScanEvent, +} from '../../static_backup'; import { normalizeQqDataRoot } from '@weq/platform'; import { intentSenseModelProcedures, isIntentModelDownloading } from './intent_sense'; import { isDbRepairBusy } from './db_repair'; +import { launchQq } from '../../qq_launch'; /** Result of the QQ data-directory picker. */ export interface PickRootResult { @@ -104,6 +103,25 @@ const algoSchema = z.object({ kdfHmacAlgorithm: z.string(), }); +function backupScanContext() { + const boot = requireBootstrap(); + const configs = boot.userConfig.listAccountConfigs(); + for (const config of configs) if (config.uid) rememberAccountUid(config.uin, config.uid); + let online: ReturnType | undefined; + return { + configs, + options: { + allowOnline: fullModeAllowed(getAppContext()), + attachHook: boot.attachHook, + keyContext: createOtherDeviceKeyContext(), + resolveOnline: () => + (online ??= import('../../capture_session').then(({ resolveOnlineInstances }) => + resolveOnlineInstances(boot, requirePlatform()), + )), + }, + }; +} + /** 导出灯箱选项快照(与渲染层 ExportOptions 一一对应)。 */ const exportPresetOptionsSchema = z.object({ range: z.object({ @@ -221,8 +239,7 @@ export const bootstrapRouter = router({ } if (boot.userConfig.getLocalModelId('intent') === input.modelId) return true; if (isIntentModelDownloading()) throw new Error('模型下载期间不能切换资源'); - // 切换资源前释放旧 worker,不能让新引用继续使用旧模型。 - await getActiveIntentSenseLocalEngine()?.stop(); + // 端口按模型目录路由;保留画像可能正在使用的旧资源,不中断其在途推断。 } else if (input.modelId) { const model = input.kind === 'voice' @@ -315,6 +332,12 @@ export const bootstrapRouter = router({ return requireBootstrap().globalConfig.refresh(); }), + /** 启动宿主上已安装的 QQ,不自动登录或更改在线功能设置。 */ + launchQq: procedure.mutation(async () => { + await launchQq(requirePlatform()); + return true; + }), + listAccounts: procedure.query(() => { return requireBootstrap().detect.listAccounts(); }), @@ -423,9 +446,12 @@ export const bootstrapRouter = router({ // ---- auto-enter target ---- - getAutoEnter: procedure.query(() => { - return requireBootstrap().userConfig.getAutoEnter(); - }), + getAutoEnter: procedure + .input(z.object({ startup: z.boolean() }).optional()) + .query(({ input }) => { + const config = requireBootstrap().userConfig; + return input?.startup ? config.getStartupAutoEnter() : config.getAutoEnter(); + }), setAutoEnter: procedure .input(z.object({ uin: z.string(), dataDir: z.string().optional() })) @@ -478,8 +504,22 @@ export const bootstrapRouter = router({ return requireBootstrap().userConfig.getSettings(); }), + setHomeSettings: procedure + .input( + z.object({ + showTags: z.boolean().optional(), + showGreeting: z.boolean().optional(), + showHitokoto: z.boolean().optional(), + blockedWords: z.array(z.string().trim().min(1).max(200)).max(1000).optional(), + }), + ) + .mutation(({ input }) => { + const config = requireBootstrap().userConfig; + return config.setSettings({ home: { ...config.getSettings().home, ...input } }).home; + }), + setExportSaveMode: procedure - .input(z.object({ mode: z.enum(['preset', 'before', 'after']) })) + .input(z.object({ mode: z.enum(['preset', 'before']) })) .mutation(({ input }) => { return requireBootstrap().userConfig.setSettings({ exportSaveMode: input.mode }) .exportSaveMode; @@ -518,7 +558,6 @@ export const bootstrapRouter = router({ services: context.services, }; }); - if (input.mode === 'after') return { mode: 'after' as const }; const host = getHost(); const settings = requireBootstrap().userConfig.getSettings(); const manager = getAppContext().services?.exportManager; @@ -611,19 +650,22 @@ export const bootstrapRouter = router({ return true; }), - /** - * Toggle 自动读取 QQ 内存(完整功能总闸). Persists, and the account monitor reads - * it live on its next poll so attach / harvesting starts or stops - * immediately (no re-open needed). 关闭 = 完全离线模式。 - * - * 三端都允许开启:macOS 也能读内存(需 root 且关闭 SIP),门槛交给用户自己 - * 决定 —— 真正读取失败时由 attach 链给出提权 / SIP 提示,不在这里预先拒绝。 - */ + /** 各窗口与 Web 客户端读取同一宿主的有效运行态。 */ + getFullModeState: procedure.query(() => ({ allowed: fullModeAllowed(getAppContext()) })), + + setRestrictedModeBannerEnabled: procedure + .input(z.object({ enabled: z.boolean() }).strict()) + .mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ restrictedModeBannerEnabled: input.enabled }); + return true; + }), + + /** 只保存自动进入策略;真实授权仍由 attach 链处理,不撤销本来源手动请求。 */ setAutoAttachQq: procedure .input(z.object({ enabled: z.boolean() })) .mutation(async ({ input }) => { requireBootstrap().userConfig.setSettings({ autoAttachQq: input.enabled }); - if (!input.enabled) getAppContext().services?.unreadInfo.cancelReports(); + getAppContext().applyFullModePolicy(); return true; }), @@ -635,6 +677,17 @@ export const bootstrapRouter = router({ return true; }), + setReadSyncEnabled: procedure + .input(z.object({ enabled: z.boolean() }).strict()) + .mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ + reportReadEnabled: input.enabled, + manualReadReportEnabled: input.enabled, + }); + if (!input.enabled) getAppContext().services?.unreadInfo.cancelReports(); + return true; + }), + setManualReadReportEnabled: procedure .input(z.object({ enabled: z.boolean() })) .mutation(({ input }) => { @@ -711,7 +764,7 @@ export const bootstrapRouter = router({ }), /** - * 头像挂件是否叠在聊天页自己的头像上。渲染层通过 App.tsx 的 SelfPendantContext + * 头像挂件是否叠在聊天页头像上。渲染层通过 App.tsx 的共享 Context * 读取——纯持久化,无需主进程侧应用。 */ setShowAvatarPendant: procedure @@ -824,6 +877,13 @@ export const bootstrapRouter = router({ return true; }), + setShowContactUnreadCount: procedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ showContactUnreadCount: input.enabled }); + return true; + }), + setJumpToLatestAfterSend: procedure .input(z.object({ enabled: z.boolean() })) .mutation(({ input }) => { @@ -831,6 +891,25 @@ export const bootstrapRouter = router({ return true; }), + setShowScrollToBottomButton: procedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ showScrollToBottomButton: input.enabled }); + return true; + }), + + setChatImageCacheMiB: procedure + .input(z.object({ miB: z.number().int().min(0).max(MAX_CHAT_IMAGE_CACHE_MIB) })) + .mutation(({ input }) => { + return requireBootstrap().userConfig.setSettings({ chatImageCacheMiB: input.miB }) + .chatImageCacheMiB; + }), + + setSendWithQqFont: procedure.input(z.object({ enabled: z.boolean() })).mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ sendWithQqFont: input.enabled }); + return true; + }), + setShowGroupAssistantMessageCount: procedure .input(z.object({ enabled: z.boolean() })) .mutation(({ input }) => { @@ -838,7 +917,19 @@ export const bootstrapRouter = router({ return true; }), - /** 是否渲染每条消息里的装扮(气泡/字体/挂件)。纯持久化。 */ + setShowMessageBubble: procedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ showMessageBubble: input.enabled }); + return true; + }), + + setShowMessageFont: procedure.input(z.object({ enabled: z.boolean() })).mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ showMessageFont: input.enabled }); + return true; + }), + + /** 兼容旧客户端的合并开关,同时更新气泡与字体。 */ setShowMsgDecoration: procedure .input(z.object({ enabled: z.boolean() })) .mutation(({ input }) => { @@ -1218,16 +1309,16 @@ export const bootstrapRouter = router({ // ---- WeQ 助手 (account-bound; renders inside QQ itself) ---- - /** Current WeQ 助手 config + live state (from the weq-daemon control pipe). */ + /** 助手配置与当前宿主进程中的资源服务状态。 */ getWeqAssistantStatus: procedure.query(async () => { const weq = requireBootstrap().userConfig.getSettings().weqAssistant; - const status = await daemonHttpStatus(); + const status = assistantResourceServer.status(); return { enabled: weq.enabled, port: weq.port, host: '127.0.0.1', url: `http://127.0.0.1:${weq.port}`, - running: status?.running ?? false, + running: status.running, }; }), @@ -1260,73 +1351,32 @@ export const bootstrapRouter = router({ return userConfig.getSettings().weqAssistant; }), - // ---- 守护进程(weq-daemon):健康 / release 监控 / 开机自启 ---- - // 设置 → 守护进程 页的数据面。守护进程本体(Rust)负责 GitHub 轮询与 - // **自身**的开机自启注册;这里只是把查询与下发暴露给渲染层。 - - /** 聚合健康快照:探活 + 版本 + HTTP + release 轮询 + 自启动注册。 */ - getDaemonHealth: procedure.query(() => { - return getDaemonHealth(); + // ---- 系统直接启动 WeQ,不依赖后台服务 ---- + getAutostartStatus: procedure.query(async (): Promise => { + const host = getHost(); + return ( + host.getAutostart?.() ?? { + supported: false, + enabled: false, + registered: false, + requiresApproval: false, + } + ); }), - /** - * 守护进程二进制是否随包就位(缺失提示先 pnpm build:daemon)。 - * 启动时会被 stage 到数据目录的稳定路径(原生自启注册只能指向那里)。 - */ - getDaemonBinaryStatus: procedure.query(() => { - return { available: resolveDaemonBinary() !== null }; + setAutostart: procedure.input(z.object({ enabled: z.boolean() })).mutation(({ input }) => { + const host = getHost(); + if (!host.canAutostart || !host.setAutostart) { + throw new Error('当前环境不支持登录自启,请使用打包安装版;Web 服务由部署方管理。'); + } + return host.setAutostart(input.enabled); }), - /** - * 开启 / 关闭 release 轮询(守护进程侧 Rust 轮询器)。开启时以当前应用 - * 版本当 current_version,轮询间隔固定 1 小时(大陆网络差也够用)。 - * GUI 提醒循环(系统通知)始终随应用启动挂着 —— 它只读守护进程的 - * 状态,watching=false 时不会产生任何提醒。 - */ - setDaemonReleaseWatch: procedure - .input(z.object({ enabled: z.boolean() })) - .mutation(async ({ input }) => { - if (!input.enabled) { - await daemonReleaseWatchStop(); - } else { - const started = await daemonReleaseWatchStart({ - api_base: 'https://api.github.com', - repo: 'weq-devs/WeQ', - interval_secs: 3600, - current_version: getHost().appVersion(), - }); - if (started === null) { - throw new Error( - '守护进程未运行,无法开启 Release 监控。WeQ 启动时会自动拉起,请稍后重试。', - ); - } - } - return daemonReleaseWatchStatus(); - }), - - /** - * 开关「开机自动启动 WeQ」—— 按约定 **GUI 不注册任何原生自启**:这里只把 - * 意图 + 当前 exe 路径交给守护进程落记忆,开机后由守护进程读记忆拉起 WeQ。 - * 需要守护进程在跑,不在则抛错由前端提示。 - */ - setDaemonAutostart: procedure + setShowWindowOnAutostart: procedure .input(z.object({ enabled: z.boolean() })) - .mutation(async ({ input }) => { - // 浏览器版 / pnpm dev:守护进程开机时要拉起一个裸可执行文件,而 web 的 - // node server.mjs(需要参数 + 工作目录)和开发态 Electron 都不具备 - // 「被裸拉起就能工作」的形态 —— 直接拒绝。 - if (!getHost().canAutostart) { - throw new Error( - '当前环境不支持开机自启:请使用打包安装版(浏览器版请用 systemd / 计划任务管理)。', - ); - } - const guiExe = getHost().currentExePath(); - const result = await daemonAutostartSet({ enabled: input.enabled, gui_exe: guiExe }); - if (result === null) { - throw new Error('守护进程未运行,无法设置开机自启。WeQ 启动时会自动拉起,请稍后重试。'); - } - if (!result.ok) throw new Error(result.message); - return daemonAutostartStatus(); + .mutation(({ input }) => { + requireBootstrap().userConfig.setSettings({ showWindowOnAutostart: input.enabled }); + return input.enabled; }), // ---- first-run onboarding (欢迎使用) ---- @@ -1496,6 +1546,23 @@ export const bootstrapRouter = router({ return requireBootstrap().agentLabConfig.listProviders(); }), + getAgentLabDefaultModel: procedure.query(() => + requireBootstrap().agentLabConfig.getDefaultModelStatus(), + ), + + setAgentLabDefaultModel: procedure + .input( + z + .object({ + ref: z + .object({ providerId: z.string().trim().min(1), model: z.string().trim().min(1) }) + .strict() + .nullable(), + }) + .strict(), + ) + .mutation(({ input }) => requireBootstrap().agentLabConfig.setDefaultModel(input.ref)), + /** 按填入的 base_url (+ api_key) 拉取厂商可用模型列表(OpenAI 兼容 GET /models)。 */ fetchAgentLabModels: procedure .input( @@ -1790,30 +1857,40 @@ export const bootstrapRouter = router({ scanStaticDirs: procedure .input(z.object({ dirPath: z.string().min(1), recursive: z.boolean().default(false) })) .mutation(async ({ input }) => { - const boot = requireBootstrap(); - const configs = boot.userConfig.listAccountConfigs(); - for (const config of configs) if (config.uid) rememberAccountUid(config.uin, config.uid); - const result = await discoverBackups(input.dirPath, input.recursive); - const candidates = []; - const keyContext = createOtherDeviceKeyContext(); - let online: - | ReturnType - | undefined; - const resolveOnline = () => - (online ??= import('../../capture_session').then(({ resolveOnlineInstances }) => - resolveOnlineInstances(boot, requirePlatform()), - )); - for (const info of result.candidates) { - const probe = await probeBackup(requirePlatform(), configs, info.dirPath, { - allowOnline: boot.userConfig.getSettings().autoAttachQq !== false, - attachHook: boot.attachHook, - keyContext, - resolveOnline, - }); - candidates.push({ ...info, probe }); - } - return { ...result, candidates }; + const { configs, options } = backupScanContext(); + return scanBackupCandidates( + requirePlatform(), + configs, + input.dirPath, + input.recursive, + options, + ); }), + scanStaticDirsProgress: procedure + .input(z.object({ dirPath: z.string().min(1), recursive: z.boolean().default(false) })) + .subscription(({ input }) => + observable((emit) => { + const controller = new AbortController(); + void (async () => { + const { configs, options } = backupScanContext(); + await scanBackupCandidates( + requirePlatform(), + configs, + input.dirPath, + input.recursive, + options, + (event) => { + if (!controller.signal.aborted) emit.next(event); + }, + controller.signal, + ); + if (!controller.signal.aborted) emit.complete(); + })().catch((error: unknown) => { + if (!controller.signal.aborted) emit.error(error); + }); + return () => controller.abort(); + }), + ), // ---- key correctness probe ---- @@ -2190,8 +2267,7 @@ export const bootstrapRouter = router({ void fetchHomeDress(platform.native.ntHelper, session, 0, input.pskey) .then(async (dress) => { services.accountConfig.setHomeDress(dress); - // 顺手把手机 QQ 正在用的气泡/字体装上并切过去。只在用户从没自己选过时 - // 动手,内部逐项静默失败(见 syncFromQq)。 + // 准备 QQ 同款装扮资源,只加入已装,不自动使用;失败不阻断登录。 await services.dressInstall.syncFromQq(dress); emitDressChanged(); }) @@ -2207,8 +2283,9 @@ export const bootstrapRouter = router({ return ctx.account!.context; }), - closeAccount: procedure.mutation(() => { + closeAccount: procedure.mutation(async () => { logger.info('router closing account', { event: 'router-close-account' }); + await getAppContext().services?.unreadInfo.dispose(); getAppContext().clearAccount(); return true; }), @@ -2242,7 +2319,7 @@ export const bootstrapRouter = router({ for (const config of configs) if (config.uid) rememberAccountUid(config.uin, config.uid); return probeBackup(requirePlatform(), configs, dbDir, { dbKey: input.dbKey, - allowOnline: boot.userConfig.getSettings().autoAttachQq !== false, + allowOnline: fullModeAllowed(getAppContext()), attachHook: boot.attachHook, resolveOnline: () => import('../../capture_session').then(({ resolveOnlineInstances }) => diff --git a/apps/desktop/src/main/ipc/routers/call.ts b/apps/desktop/src/main/ipc/routers/call.ts index 29c2edd0d..25bd4ee10 100644 --- a/apps/desktop/src/main/ipc/routers/call.ts +++ b/apps/desktop/src/main/ipc/routers/call.ts @@ -179,27 +179,40 @@ export const callRouter = router({ hevc: Uint8Array; timestamp: number; keyframe: boolean; + /** 16 位帧序号;渲染层据此判断喂入是否连续(见 useCallVideo)。 */ + frameIndex: number; info: VideoStreamInfo | null; }>((emit) => { const manager = findCallManager(); if (!manager) return () => {}; - // 先补最近的关键帧:订阅是在「对端开始发视频」之后才建立的,首个关键帧 - // 往往已经过去,没有它解码器起不来(后续 delta 帧会全被丢弃)。 - const kf = manager.latestKeyframe(); - if (kf?.hevc) { + // 补发「关键帧 + 其后的连续帧」:订阅是在「对端开始发视频」之后才建立的, + // 首个关键帧往往已经过去。 + // + // ⚠️ **不能只补关键帧。** 只补关键帧的话,渲染层喂完它之后紧接着收到的是 + // 实时帧,中间缺一大段;HEVC 的 P 帧依赖前序参考帧,硬解会输出错乱的 YUV + // (整屏纯绿,约 15 秒后才被下一个关键帧纠正)。整段补发让渲染层从头连续 + // 解码,代价是订阅瞬间多传几十帧。 + const replay = manager.replayFrames(); + for (const frame of replay) { + if (!frame.hevc) continue; emit.next({ - hevc: kf.hevc, - timestamp: kf.timestamp, - keyframe: true, - info: kf.info, + hevc: frame.hevc, + timestamp: frame.timestamp, + keyframe: frame.isKeyframe, + frameIndex: frame.frameIndex, + info: frame.info, }); } + // 补发期间实时帧可能已经往前走了,两者会重叠(补发到帧 N,实时流已从帧 M + // 开始)。重叠与跳帧由渲染层的 `acceptsFrame` 统一处理:连续的照常解, + // 不连续的就丢弃等关键帧 —— 这里不做额外过滤,避免两处逻辑各自为政。 const handler = (event: CallEvent) => { if (event.type !== 'video') return; emit.next({ hevc: event.hevc, timestamp: event.timestamp, keyframe: event.keyframe, + frameIndex: event.frameIndex, info: event.info, }); }; diff --git a/apps/desktop/src/main/ipc/routers/clear_completed_tasks.ts b/apps/desktop/src/main/ipc/routers/clear_completed_tasks.ts new file mode 100644 index 000000000..d1f038625 --- /dev/null +++ b/apps/desktop/src/main/ipc/routers/clear_completed_tasks.ts @@ -0,0 +1,53 @@ +import type { AccountServices } from '../../context/app_context'; + +export interface CompletedTaskSelection { + exports: string[]; + roaming: string[]; + flash: string[]; +} + +/** 只处理确认时的 ID;再次核对状态,不扩展到后来完成的任务。 */ +export async function clearCompletedTasks( + services: Pick, + selection: CompletedTaskSelection, +) { + let removed = 0; + let skipped = 0; + const errors: string[] = []; + for (const id of new Set(selection.exports)) { + try { + const task = services.exportManager.getTask(id); + if ( + task?.status === 'completed' && + task.save?.status !== 'saving' && + task.save?.status !== 'failed' && + task.save?.status !== 'interrupted' && + services.exportManager.deleteTask(id) + ) + removed += 1; + else skipped += 1; + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + for (const id of new Set(selection.roaming)) { + try { + if ( + services.roamingBackfill.list().some((job) => job.id === id && job.status === 'completed') + ) { + services.roamingBackfill.remove(id); + removed += 1; + } else skipped += 1; + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + try { + const count = await services.flashTransferFiles.downloads.clearCompleted(selection.flash); + removed += count; + skipped += new Set(selection.flash).size - count; + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + return { removed, skipped, errors }; +} diff --git a/apps/desktop/src/main/ipc/routers/dressup.ts b/apps/desktop/src/main/ipc/routers/dressup.ts index 689427d57..839d652b6 100644 --- a/apps/desktop/src/main/ipc/routers/dressup.ts +++ b/apps/desktop/src/main/ipc/routers/dressup.ts @@ -30,6 +30,7 @@ import { requireAttachEnabled, type AccountServices, } from '../../context/app_context'; +import { fullModeAllowed } from '../../context/full_mode'; import { resolveResource } from '../../resource'; import { procedure, router } from '../trpc'; import { buildDecorationBundle, buildDecorationFile } from './dressup_export'; @@ -52,7 +53,7 @@ function requireServices(): AccountServices { function qqOnline(services = requireServices()): boolean { const record = services.accountConfig.getRecord(); if (!(record?.qqOnline && record.qqPid)) return false; - return getAppContext().bootstrap?.userConfig.getSettings().autoAttachQq ?? true; + return fullModeAllowed(getAppContext()); } /** 仅要求在线 QQ 实例(不要求注入)—— 走 Web CGI 的接口用(pt_login 可兜底)。 */ @@ -496,6 +497,7 @@ export const dressupRouter = router({ host, output, current, + `dressup:${input.kind ?? 'bundle'}`, ); if (!policy) return { mode: 'cancelled' as const }; current(); diff --git a/apps/desktop/src/main/ipc/routers/export_save.ts b/apps/desktop/src/main/ipc/routers/export_save.ts index 010081da0..a6cf04c37 100644 --- a/apps/desktop/src/main/ipc/routers/export_save.ts +++ b/apps/desktop/src/main/ipc/routers/export_save.ts @@ -28,7 +28,7 @@ export function exportDownloadName( } export const exportSavePolicyInput = z.object({ - mode: z.enum(['preset', 'before', 'after']), + mode: z.enum(['preset', 'before']), directory: z.string().min(1).optional(), filePath: z.string().min(1).optional(), uniqueFileName: z.boolean().optional(), @@ -68,33 +68,41 @@ export async function prepareExportDestination( host: Pick, output?: ExportOutputShape, current: () => void = () => {}, + locationKey = output?.kind === 'file' ? `export:file:${output.extension}` : 'export:directory', ): Promise { current(); - if (input.mode === 'after') return { mode: 'after' }; + if (input.mode !== 'preset' && input.mode !== 'before') + throw new Error('不支持此保存方式,请在开始前选择保存位置。'); if (input.directory && input.filePath) throw new Error('保存位置只能选择文件或目录之一。'); if (input.uniqueFileName && !input.filePath && output?.kind !== 'file') throw new Error('自动文件命名仅支持单文件位置。'); - let mode = input.mode; + const mode = input.mode; let filePath = input.filePath; let directory = input.directory ?? (mode === 'preset' ? defaultDirectory : undefined); let uniqueFileName = input.uniqueFileName; if (filePath && output?.kind === 'directory') throw new Error('结果包含多个文件,请选择保存文件夹。'); - if (!filePath && !directory && !host.exportRoot) { - mode = 'before'; + if (!filePath && !directory && !host.exportRoot && mode === 'before') { if (output?.kind === 'file') { const selected = await host.pickSaveTarget({ - locationKey: 'export-before', + locationKey, + defaultDirectory: defaultDirectory ?? undefined, defaultName: exportOutputFileName(output), extension: output.extension, }); if (!selected) return null; filePath = selected.path; } else { - directory = await host.pickDirectory({ title: '选择本次导出保存位置' }); + directory = await host.pickDirectory({ + title: '选择本次导出保存位置', + locationKey, + defaultDirectory: defaultDirectory ?? undefined, + }); if (!directory) return null; } } + if (!filePath && !directory && !host.exportRoot) + throw new Error('自动保存需要默认保存文件夹,请先在设置中选择。'); current(); if (!filePath) { directory = host.exportRoot @@ -179,6 +187,7 @@ export async function saveCompletedExportTask( host: () => Pick, selected: Pick = {}, check: () => void = () => {}, + preferences?: { exportSaveMode: ExportSavePolicy['mode']; defaultExportDir: string | null }, ): Promise { check(); const task = manager.getTask(taskId); @@ -187,9 +196,24 @@ export async function saveCompletedExportTask( const explicit = selected.directory || selected.filePath; if (selected.uniqueFileName && !selected.filePath) throw new Error('自动文件命名仅支持单文件位置。'); + const output: ExportOutputShape = task.bundleDir + ? { kind: 'directory' } + : (task.resource?.output ?? { + kind: 'file', + name: task.name, + extension: task.format === 'vcard' ? 'vcf' : task.format, + }); const policy = explicit ? copyExportSavePolicy({ mode: 'before', ...selected }) - : await pickExportOutputDestination(task, host()); + : preferences + ? await prepareExportDestination( + { mode: preferences.exportSaveMode }, + preferences.defaultExportDir, + host(), + output, + check, + ) + : await pickExportOutputDestination(task, host()); if (!policy) return null; check(); return manager.saveTaskOutput(taskId, policy.directory, policy.filePath, policy.uniqueFileName); diff --git a/apps/desktop/src/main/ipc/routers/export_viewer.ts b/apps/desktop/src/main/ipc/routers/export_viewer.ts index 9f7730c1e..75e7ff792 100644 --- a/apps/desktop/src/main/ipc/routers/export_viewer.ts +++ b/apps/desktop/src/main/ipc/routers/export_viewer.ts @@ -1,6 +1,7 @@ import { dirname } from 'node:path'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; +import { observable } from '@trpc/server/observable'; import { classifyChatType } from '@weq/codec'; import { describeViewerFile, discoverViewerSources, getHost, readViewerPage } from '@weq/service'; import { getAppContext } from '../../context/app_context'; @@ -16,6 +17,17 @@ import { pushConversationJump } from '../../group_keyword_bus'; import { procedure, router } from '../trpc'; const pendingScans = new Map(); +function scanToken(path: string) { + for (const [token, scan] of pendingScans) + if (scan.expires < Date.now()) pendingScans.delete(token); + if (pendingScans.size >= 16) pendingScans.delete(pendingScans.keys().next().value!); + const token = randomUUID(); + pendingScans.set(token, { path, expires: Date.now() + 10 * 60_000 }); + return token; +} +export type ViewerScanEvent = + | { kind: 'source'; source: ReturnType } + | { kind: 'done'; truncated: boolean; unreadable: number; scanToken: string | null }; function registerExports(result: Awaited>) { const registered: string[] = []; try { @@ -53,43 +65,44 @@ async function local(id: string) { return targets.size === 1 ? { kind: conv.kind, conv: [...targets][0]!, uin, configId } : null; } export const exportViewerRouter = router({ - pick: procedure.input(z.object({ directory: z.boolean() })).mutation(async ({ input }) => { - const path = input.directory - ? await getHost().pickDirectory({ title: '选择 WeQ 导出文件夹' }) - : await getHost().pickFile({ title: '选择聊天 JSON / JSONL', extensions: ['json', 'jsonl'] }); - if (!path) return null; - if (input.directory) { - const result = await discoverExportDirectories(path); - if (!result.sources.length) { - for (const [token, scan] of pendingScans) { - if (scan.expires < Date.now()) pendingScans.delete(token); + pick: procedure + .input(z.object({ directory: z.boolean(), deferScan: z.boolean().optional() })) + .mutation(async ({ input }) => { + const path = input.directory + ? await getHost().pickDirectory({ title: '选择 WeQ 导出文件夹' }) + : await getHost().pickFile({ + title: '选择聊天 JSON / JSONL', + extensions: ['json', 'jsonl'], + }); + if (!path) return null; + if (input.directory) { + if (input.deferScan) + return { sources: [], truncated: false, unreadable: 0, scanToken: scanToken(path) }; + const result = await discoverExportDirectories(path); + if (!result.sources.length) { + return { ...registerExports(result), scanToken: scanToken(path) }; } - if (pendingScans.size >= 16) pendingScans.delete(pendingScans.keys().next().value!); - const scanToken = randomUUID(); - pendingScans.set(scanToken, { path, expires: Date.now() + 10 * 60_000 }); - return { ...registerExports(result), scanToken }; + return registerExports(result); } - return registerExports(result); - } - const source = await describeViewerFile(path); - // 配套基线必须唯一且会话一致,不能仅凭文件名或最新时间猜测。 - const catalog = await discoverViewerSources(dirname(source.path)).catch(() => null); - const matches = - catalog?.sources.filter( - (s) => - s.baselineDirectory && - s.conversation?.kind === source.conversation?.kind && - s.conversation?.conv === source.conversation?.conv, - ) ?? []; - if (!catalog?.truncated && matches.length === 1) - source.baselineDirectory = matches[0]?.baselineDirectory; - return { - sources: [registerViewerSource(source)], - truncated: false, - scanToken: null, - unreadable: 0, - }; - }), + const source = await describeViewerFile(path); + // 配套基线必须唯一且会话一致,不能仅凭文件名或最新时间猜测。 + const catalog = await discoverViewerSources(dirname(source.path)).catch(() => null); + const matches = + catalog?.sources.filter( + (s) => + s.baselineDirectory && + s.conversation?.kind === source.conversation?.kind && + s.conversation?.conv === source.conversation?.conv, + ) ?? []; + if (!catalog?.truncated && matches.length === 1) + source.baselineDirectory = matches[0]?.baselineDirectory; + return { + sources: [registerViewerSource(source)], + truncated: false, + scanToken: null, + unreadable: 0, + }; + }), scan: procedure .input(z.object({ token: z.string().uuid(), recursive: z.boolean() })) .mutation(async ({ input }) => { @@ -101,6 +114,50 @@ export const exportViewerRouter = router({ if (!result.sources.length) throw new Error('递归扫描未找到可查看的导出。'); return registerExports(result); }), + scanProgress: procedure + .input(z.object({ token: z.string().uuid(), recursive: z.boolean() })) + .subscription(({ input }) => + observable((emit) => { + const controller = new AbortController(), + registered: string[] = []; + let finished = false; + const pending = pendingScans.get(input.token); + pendingScans.delete(input.token); + void (async () => { + if (!pending || pending.expires < Date.now()) + throw new Error('目录选择已过期,请重新选择。'); + const result = await discoverExportDirectories(pending.path, input.recursive, { + signal: controller.signal, + onSource: (source) => { + controller.signal.throwIfAborted(); + const item = registerViewerSource(source); + registered.push(item.id); + emit.next({ kind: 'source', source: item }); + }, + }); + controller.signal.throwIfAborted(); + let token: string | null = null; + if (!registered.length && !input.recursive) token = scanToken(pending.path); + finished = true; + emit.next({ + kind: 'done', + truncated: result.truncated, + unreadable: result.unreadable, + scanToken: token, + }); + emit.complete(); + })().catch((error: unknown) => { + if (controller.signal.aborted) return; + // 已发给界面的部分来源继续可读,界面负责关闭;异常不冒称扫描完成。 + finished = true; + emit.error(error); + }); + return () => { + controller.abort(); + if (!finished) closeViewerSources(registered); + }; + }), + ), page: procedure .input( z.object({ diff --git a/apps/desktop/src/main/ipc/routers/group_feedback.ts b/apps/desktop/src/main/ipc/routers/group_feedback.ts index 6052d4466..a94120110 100644 --- a/apps/desktop/src/main/ipc/routers/group_feedback.ts +++ b/apps/desktop/src/main/ipc/routers/group_feedback.ts @@ -16,6 +16,7 @@ import { existsSync } from 'node:fs'; import type { NtHelperBinding } from '@weq/native'; import { getLogger } from '@weq/service'; import { getAppContext, type AccountServices } from '../../context/app_context'; +import { fullModeAllowed } from '../../context/full_mode'; import { resolveResource } from '../../resource'; import { procedure, router } from '../trpc'; import { PROJECT_GROUP_IDS } from '../../../shared/project_groups'; @@ -57,7 +58,7 @@ function requireOnlineQq(): { if (!nt || !uin || !record?.qqOnline || !record.qqPid) { throw new Error('需要先登录该账号的 QQ 客户端。'); } - if (ctx.bootstrap?.userConfig.getSettings().autoAttachQq === false) { + if (!fullModeAllowed(ctx)) { throw new Error('已开启完全离线模式(自动读取 QQ 内存 已关闭),反馈发送需要在线 QQ。'); } return { nt, pid: record.qqPid, uin }; @@ -148,8 +149,7 @@ export const groupFeedbackRouter = router({ try { online = requireOnlineQq(); } catch (e) { - const offlineMode = - getAppContext().bootstrap?.userConfig.getSettings().autoAttachQq === false; + const offlineMode = !fullModeAllowed(getAppContext()); return { ok: false, reason: offlineMode ? ('offline-mode' as const) : ('offline' as const), @@ -241,8 +241,7 @@ export const groupFeedbackRouter = router({ try { online = requireOnlineQq(); } catch (e) { - const offlineMode = - getAppContext().bootstrap?.userConfig.getSettings().autoAttachQq === false; + const offlineMode = !fullModeAllowed(getAppContext()); return { ok: false, reason: offlineMode ? ('offline-mode' as const) : ('offline' as const), diff --git a/apps/desktop/src/main/ipc/routers/intent_sense.ts b/apps/desktop/src/main/ipc/routers/intent_sense.ts index 2fc91a3a4..dec4ff30d 100644 --- a/apps/desktop/src/main/ipc/routers/intent_sense.ts +++ b/apps/desktop/src/main/ipc/routers/intent_sense.ts @@ -14,6 +14,7 @@ */ import { z } from 'zod'; +import { agentLabModelSelectionSchema } from '../../../shared/agentlab_model_selection'; import { observable } from '@trpc/server/observable'; import { getActiveIntentSenseLocalEngine, @@ -57,6 +58,8 @@ const configPatch = z.object({ suggestionCount: tuning('suggestionCount'), suggestionTargetChars: tuning('suggestionTargetChars'), profileTemperature: tuning('profileTemperature'), + profileApiTimeoutSeconds: tuning('profileApiTimeoutSeconds'), + profileApiCooldownSeconds: tuning('profileApiCooldownSeconds'), profileDecayDays: tuning('profileDecayDays'), profileModelTopics: tuning('profileModelTopics'), profileCacheSeconds: tuning('profileCacheSeconds'), @@ -74,22 +77,14 @@ const configPatch = z.object({ profileGapMinutes: tuning('profileGapMinutes'), profileTrendDays: tuning('profileTrendDays'), profileTopicLimit: tuning('profileTopicLimit'), - profileEngine: z.enum(['follow', 'api', 'local', 'heuristic']).optional(), + profileEngine: z.enum(['api', 'local', 'heuristic']).optional(), + profileLocalModelId: z.enum(['auto', 'laya-multilingual', 'QWeQ-NineBird-v1']).optional(), profileEnabled: z.boolean().optional(), - profileProviderRef: z - .object({ providerId: z.string().min(1), model: z.string().min(1) }) - .nullable() - .optional(), + profileProviderRef: agentLabModelSelectionSchema.nullable().optional(), enabled: z.boolean().optional(), engine: z.enum(['api', 'local']).optional(), - providerRef: z - .object({ providerId: z.string().min(1), model: z.string().min(1) }) - .nullable() - .optional(), - suggestionProviderRef: z - .object({ providerId: z.string().min(1), model: z.string().min(1) }) - .nullable() - .optional(), + providerRef: agentLabModelSelectionSchema.nullable().optional(), + suggestionProviderRef: agentLabModelSelectionSchema.nullable().optional(), suggestionsEnabled: z.boolean().optional(), analyzeAfterSelf: z.boolean().optional(), persistentDisplay: z.boolean().optional(), @@ -182,7 +177,9 @@ const intentSenseFeatureProcedures = { /** 对方互动画像;无可分析对方消息时为 null。仅私聊有语义。 */ getProfile: procedure - .input(z.object({ peerUid: z.string().min(1) })) + .input( + z.object({ peerUid: z.string().min(1), revision: z.number().int().nonnegative().optional() }), + ) .query(({ input }) => requireServices().intentSense.getProfile(input.peerUid)), /** 候选回复(当前引擎可用且 suggestionsEnabled)。 */ @@ -235,7 +232,12 @@ export const intentSenseModelProcedures = { * 运行中重复调用直接返回,不打断在途下载。 */ downloadLocalModel: procedure - .input(z.object({ mirror: z.boolean() })) + .input( + z.object({ + mirror: z.boolean(), + modelId: z.enum(['auto', 'laya-multilingual', 'QWeQ-NineBird-v1']).optional(), + }), + ) .mutation(async ({ input }) => { const engine = getActiveIntentSenseLocalEngine(); if (!engine) return { ok: false, error: '本地引擎仅桌面版可用', alreadyRunning: false }; @@ -259,7 +261,7 @@ export const intentSenseModelProcedures = { done: false, }); try { - await engine.download({ mirror: input.mirror, onProgress: notify }); + await engine.download({ modelId: input.modelId, mirror: input.mirror, onProgress: notify }); return { ok: true, alreadyRunning: false }; } catch (error) { return { ok: false, error: String(error), alreadyRunning: false }; diff --git a/apps/desktop/src/main/ipc/routers/media_resource.ts b/apps/desktop/src/main/ipc/routers/media_resource.ts index 22f87ac72..7759cf3c7 100644 --- a/apps/desktop/src/main/ipc/routers/media_resource.ts +++ b/apps/desktop/src/main/ipc/routers/media_resource.ts @@ -13,6 +13,11 @@ import { z } from 'zod'; import { getAppContext, type AccountServices } from '../../context/app_context'; import type { TranscribeResult } from '../../transcribe/engine'; import { procedure, router } from '../trpc'; +import { + resourceSourceInput, + requireResourceSource, + assertResourceSource, +} from './resource_source'; function requireServices(): AccountServices { const ctx = getAppContext(); @@ -61,9 +66,14 @@ export const mediaResourceRouter = router({ * the slow, per-file `stat` walk can show progress). Returns count/bytes, * a by-month breakdown, and an original-vs-thumbnail split. */ - analyzeTree: procedure.input(z.object({ key: treeKey })).query(({ input }) => { - return requireServices().mediaResource.analyzeTree(input.key); - }), + analyzeTree: procedure + .input(resourceSourceInput.extend({ key: treeKey })) + .query(async ({ input }) => { + const services = requireResourceSource(input); + const stat = await services.mediaResource.analyzeTree(input.key); + assertResourceSource(services, input); + return stat; + }), /** * Resolve ONE message media element to its on-disk file. diff --git a/apps/desktop/src/main/ipc/routers/media_save.ts b/apps/desktop/src/main/ipc/routers/media_save.ts index ec3562461..ba58ff039 100644 --- a/apps/desktop/src/main/ipc/routers/media_save.ts +++ b/apps/desktop/src/main/ipc/routers/media_save.ts @@ -373,6 +373,7 @@ export const mediaSaveRouter = router({ getHost(), output, check, + `media:${selected.source.kind}:${output.extension}`, ); if (!policy) return null; const fileName = exportOutputFileName(output); diff --git a/apps/desktop/src/main/ipc/routers/qq_actions.ts b/apps/desktop/src/main/ipc/routers/qq_actions.ts index 64e9cbf10..8bbc36056 100644 --- a/apps/desktop/src/main/ipc/routers/qq_actions.ts +++ b/apps/desktop/src/main/ipc/routers/qq_actions.ts @@ -600,7 +600,7 @@ export function createQQActionsRouter( if (resolve(i) !== s || s.accountConfig.getRecord()?.qqPid !== pid) throw new Error('账号或 QQ 运行时已改变,请重新导出'); }; - if (i.savePolicy.mode !== 'after' && !i.savePolicy.directory && !i.savePolicy.filePath) + if (!i.savePolicy.directory && !i.savePolicy.filePath) throw new Error('请先选择收藏包保存位置'); return s.exportManager.startResourceExport({ kind: 'favorite-emoji', diff --git a/apps/desktop/src/main/ipc/routers/remote.ts b/apps/desktop/src/main/ipc/routers/remote.ts index 8f1dbbc10..9429d964e 100644 --- a/apps/desktop/src/main/ipc/routers/remote.ts +++ b/apps/desktop/src/main/ipc/routers/remote.ts @@ -34,6 +34,12 @@ export const remoteRouter = router({ setMode: local .input(z.object({ mode: remoteConnectionMode })) .mutation(async ({ input }) => remoteRuntime.setMode(input.mode)), + setTailscaleEnabled: local + .input(z.object({ enabled: z.boolean() })) + .mutation(async ({ input }) => { + await remoteRuntime.setTailscaleEnabled(input.enabled); + return remoteRuntime.status(); + }), changeNetwork: local.mutation(() => remoteRuntime.changeNetwork()), refreshNetwork: local.mutation(() => remoteRuntime.refreshNetwork()), openLogin: local.mutation(async () => { diff --git a/apps/desktop/src/main/ipc/routers/resource_cleanup.ts b/apps/desktop/src/main/ipc/routers/resource_cleanup.ts index 0b0a0a0cd..034b184d3 100644 --- a/apps/desktop/src/main/ipc/routers/resource_cleanup.ts +++ b/apps/desktop/src/main/ipc/routers/resource_cleanup.ts @@ -8,21 +8,21 @@ */ import { z } from 'zod'; -import { getAppContext, type AccountServices } from '../../context/app_context'; +import { getAppContext } from '../../context/app_context'; import { procedure, router } from '../trpc'; - -function requireServices(): AccountServices { - const ctx = getAppContext(); - if (!ctx.services) { - throw new Error('No account session open — call bootstrap.openAccount first.'); - } - return ctx.services; -} +import { + resourceSourceInput, + requireResourceSource, + assertResourceSource, +} from './resource_source'; export const resourceCleanupRouter = router({ /** Per-target on-disk size (files/bytes + ori/thumb split). Slow — stats every file. */ - listTargets: procedure.query(() => { - return requireServices().resourceCleanup.listTargets(); + listTargets: procedure.input(resourceSourceInput.optional()).query(async ({ input }) => { + const services = requireResourceSource(input); + const targets = await services.resourceCleanup.listTargets(); + assertResourceSource(services, input); + return targets; }), /** @@ -32,7 +32,7 @@ export const resourceCleanupRouter = router({ */ cleanup: procedure .input( - z.object({ + resourceSourceInput.extend({ instructions: z.array( z.object({ id: z.string().min(1), @@ -41,7 +41,7 @@ export const resourceCleanupRouter = router({ ), }), ) - .mutation(({ input }) => { + .mutation(async ({ input }) => { // Never delete from a static account. Its uin may match an account that // IS installed locally, in which case these paths resolve to that other // account's files — deleting them would destroy data the user never @@ -50,6 +50,9 @@ export const resourceCleanupRouter = router({ if (getAppContext().accountIsStatic) { throw new Error('静态账号不提供本地资源清理:这些资源不属于导入的数据库快照。'); } - return requireServices().resourceCleanup.cleanup(input.instructions); + const services = requireResourceSource(input); + const result = await services.resourceCleanup.cleanup(input.instructions); + assertResourceSource(services, input); + return result; }), }); diff --git a/apps/desktop/src/main/ipc/routers/resource_source.ts b/apps/desktop/src/main/ipc/routers/resource_source.ts new file mode 100644 index 000000000..220e54fb0 --- /dev/null +++ b/apps/desktop/src/main/ipc/routers/resource_source.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { getAppContext, type AccountServices } from '../../context/app_context'; + +/** 旧工具可省略身份;融合工具始终传完整账号和配置来源。 */ +export const resourceSourceInput = z.object({ + expectedAccountUin: z.string().min(1).optional(), + expectedAccountConfigId: z.string().min(1).optional(), +}); + +export function requireResourceSource( + input?: z.infer, +): AccountServices { + const context = getAppContext(); + if ( + input?.expectedAccountUin && + String(context.account?.context.uin ?? '') !== input.expectedAccountUin + ) + throw new Error('账号已切换,已停止资源操作'); + if ( + input?.expectedAccountConfigId && + context.services?.accountConfig.getRecord()?.configId !== input.expectedAccountConfigId + ) + throw new Error('账号数据源已切换,已停止资源操作'); + if (!context.services) throw new Error('当前未打开账号'); + return context.services; +} + +export function assertResourceSource( + services: AccountServices, + input?: z.infer, +): void { + if (requireResourceSource(input) !== services) + throw new Error('账号实例已切换,已丢弃旧资源结果'); +} diff --git a/apps/desktop/src/main/ipc/routers/wonderful_tools.ts b/apps/desktop/src/main/ipc/routers/wonderful_tools.ts index b013031a2..a806ae5fd 100644 --- a/apps/desktop/src/main/ipc/routers/wonderful_tools.ts +++ b/apps/desktop/src/main/ipc/routers/wonderful_tools.ts @@ -25,6 +25,7 @@ import { z } from 'zod'; import { EventEmitter } from 'node:events'; import { existsSync } from 'node:fs'; +import { connectLongTermAuthorization } from '../../authorization/client'; import { open as openFile, writeFile } from 'node:fs/promises'; import { observable } from '@trpc/server/observable'; import { readSipEnabled, type KeyScanProgress, type KeyScanResult } from '@weq/native'; @@ -41,7 +42,13 @@ import { loginDbPaths, resolveLoginAccountDir, } from '@weq/service'; -import { rememberAccountUid, requireBootstrap, requirePlatform } from '../../context/app_context'; +import { + getAppContext, + rememberAccountUid, + requireBootstrap, + requirePlatform, +} from '../../context/app_context'; +import { fullModeAllowed } from '../../context/full_mode'; import { procedure, router } from '../trpc'; import { ensureUidForUin } from './bootstrap'; import type { CaptureBatchWire, CaptureStatsWire } from '../../capture_protocol'; @@ -73,6 +80,7 @@ export interface CaptureSupportWire { elevated: boolean; hint: string; platform: string; + longTermAuthorized?: boolean; } /** 导出抓包结果时落盘的文件格式。 */ @@ -362,8 +370,11 @@ export const wonderfulToolsRouter = router({ * 走 `probeCaptureBackend()` 而不是直接调 native —— 老产物里没有 * `probeCaptureSupport` 时也返回一条带引导的「不可用」,别把 TypeError 抛给界面。 */ - captureSupport: procedure.query((): CaptureSupportWire => { - return { ...probeCaptureBackend(), platform: process.platform }; + captureSupport: procedure.query(async (): Promise => { + const connection = await connectLongTermAuthorization(); + const longTermAuthorized = connection !== null; + connection?.close(); + return { ...probeCaptureBackend(), platform: process.platform, longTermAuthorized }; }), /** @@ -388,6 +399,7 @@ export const wonderfulToolsRouter = router({ pid: handle.pid, elevated: handle.elevated, hasD2Key: handle.hasD2Key, + sessionId: handle.sessionId, }; }), @@ -399,6 +411,7 @@ export const wonderfulToolsRouter = router({ .input( z.object({ pid: z.number().int().positive(), + sessionId: z.string().min(1).max(128).optional(), cursor: z.number().int().nonnegative().optional(), waitMs: z.number().int().min(0).max(10_000).optional(), }), @@ -407,14 +420,20 @@ export const wonderfulToolsRouter = router({ return await takeAccountFrames(input.pid, { cursor: input.cursor, waitMs: input.waitMs ?? 1200, + sessionId: input.sessionId, }); }), /** 停止抓包并释放网卡会话。会话已不存在时返回 null(前端幂等停止)。 */ captureStop: procedure - .input(z.object({ pid: z.number().int().positive() })) + .input( + z.object({ + pid: z.number().int().positive(), + sessionId: z.string().min(1).max(128).optional(), + }), + ) .mutation(async ({ input }): Promise => { - return await stopAccountCapture(input.pid); + return await stopAccountCapture(input.pid, input.sessionId); }), /** @@ -536,7 +555,7 @@ export const wonderfulToolsRouter = router({ if (!existsSync(input.dbPath)) { return { success: false, error: `未找到数据库文件:${input.dbPath}` }; } - if (boot.userConfig.getSettings().autoAttachQq === false) { + if (!fullModeAllowed(getAppContext())) { return { success: false, error: '已开启完全离线模式(自动读取 QQ 内存 已关闭),无法向在线 QQ 请求密钥。', diff --git a/apps/desktop/src/main/key_scan_elevation.ts b/apps/desktop/src/main/key_scan_elevation.ts index b16a4e128..ddf6501be 100644 --- a/apps/desktop/src/main/key_scan_elevation.ts +++ b/apps/desktop/src/main/key_scan_elevation.ts @@ -32,6 +32,7 @@ import type { KeyScanProgress, KeyScanResult } from '@weq/native'; import { getLogger } from '@weq/service'; import { installInvisibleToRoot } from './attach_elevation'; import { requestSudoPassword } from './sudo_prompt'; +import { tryLongTermKeyScan } from './authorization/client'; const logger = getLogger().child({ scope: 'key-scan-elevation' }); @@ -221,6 +222,13 @@ export interface ElevatedKeyScanOptions { export async function scanKeyElevated(options: ElevatedKeyScanOptions): Promise { const { dbPath, pid, runInProcess, onProgress } = options; + try { + const result = await tryLongTermKeyScan(dbPath, pid, onProgress); + if (result) return result; + } catch { + // 长期授权不可用时继续原来的临时授权,不将其当作扫描成功。 + } + let password = ''; if (!(await sudoCredentialsCached())) { const entered = await requestSudoPassword( diff --git a/apps/desktop/src/main/legacy_daemon.ts b/apps/desktop/src/main/legacy_daemon.ts new file mode 100644 index 000000000..693757598 --- /dev/null +++ b/apps/desktop/src/main/legacy_daemon.ts @@ -0,0 +1,166 @@ +import { execFile } from 'node:child_process'; +import { readFile, rm } from 'node:fs/promises'; +import { join, win32 } from 'node:path'; +import { promisify } from 'node:util'; +import { DOMParser } from '@xmldom/xmldom'; +import { daemonStop, pingDaemon } from '@weq/service'; + +export type RunCommand = (command: string, args: string[]) => Promise<{ stdout: string }>; +const exec = promisify(execFile); +export const runCommand: RunCommand = (command, args) => + exec(command, args, { timeout: 8000, windowsHide: true }); + +export function legacyDaemonStateDir( + platform: NodeJS.Platform, + home: string, + env: NodeJS.ProcessEnv, +): string { + if (platform === 'win32') return join(env.LOCALAPPDATA ?? home, 'weq-daemon'); + if (platform === 'darwin') return join(home, 'Library', 'Application Support', 'weq-daemon'); + return join(env.XDG_STATE_HOME ?? join(home, '.local', 'state'), 'weq-daemon'); +} + +async function optionalFile(path: string): Promise { + try { + return await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +/** 只退役 WeQ 的固定旧入口,不执行旧二进制、不删除旧数据、不修改系统全局 linger。 */ +export async function retireLegacyDaemon(options: { + platform: NodeJS.Platform; + home: string; + env: NodeJS.ProcessEnv; + run?: RunCommand; + uid?: number; +}): Promise { + const { platform, home, env, uid = process.getuid?.() } = options; + const run = options.run ?? runCommand; + if (platform === process.platform && (platform === 'darwin' || platform === 'linux')) { + const parent = await run('/bin/ps', ['-p', String(process.ppid), '-o', 'comm=']).catch(() => ({ + stdout: '', + })); + if (/(?:^|\/)weq-daemon(?:\s|$)/i.test(parent.stdout.trim())) { + throw new Error('当前 WeQ 由旧后台启动;请完全退出后从系统启动器重新打开,再完成旧后台迁移'); + } + } + for (const label of ['weq-daemon', 'weq-daemon-gui']) { + if (platform === 'darwin') { + const path = join(home, 'Library', 'LaunchAgents', `${label}.plist`); + if ((await optionalFile(path)) === null) continue; + const { stdout } = await run('/usr/bin/plutil', ['-convert', 'json', '-o', '-', path]); + const parsed: unknown = JSON.parse(stdout); + if ( + !parsed || + typeof parsed !== 'object' || + !('Label' in parsed) || + parsed.Label !== label || + !('ProgramArguments' in parsed) || + !Array.isArray(parsed.ProgramArguments) || + !parsed.ProgramArguments.some((arg: unknown) => typeof arg === 'string' && /weq/i.test(arg)) + ) + throw new Error(`旧服务 ${label} 的配置不匹配,未移除`); + if (uid === undefined) throw new Error('无法确定旧服务所属用户'); + // 旧 GUI 登录项可能就是当前应用,不能 bootout 杀掉自己;一次性登录触发只需移除文件。 + if (label === 'weq-daemon-gui') { + if ('KeepAlive' in parsed && parsed.KeepAlive) + throw new Error('旧 GUI 登录项仍设为常驻,请停用旧入口后重开 WeQ'); + await rm(path); + continue; + } + const target = `gui/${uid}/${label}`; + try { + await run('/bin/launchctl', ['bootout', target]); + } catch (error) { + const loaded = await run('/bin/launchctl', ['print', target]).then( + () => true, + () => false, + ); + if (loaded) throw error; + } + await rm(path); + } else if (platform === 'linux') { + const path = join(home, '.config', 'systemd', 'user', `${label}.service`); + const text = await optionalFile(path); + if (text === null) continue; + if (!/weq/i.test(text)) throw new Error(`旧服务 ${label} 的配置不匹配,未移除`); + if (platform === process.platform) { + const { stdout } = await run('systemctl', [ + '--user', + 'show', + `${label}.service`, + '--property=ControlGroup', + '--value', + ]); + const group = stdout.trim(); + const ownGroups = await readFile('/proc/self/cgroup', 'utf8'); + if ( + group.startsWith('/') && + group !== '/' && + ownGroups.split('\n').some((line) => { + const path = line.slice(line.lastIndexOf(':') + 1); + return path === group || path.startsWith(`${group}/`); + }) + ) + throw new Error('当前 WeQ 仍属于旧后台服务,请完全退出后从系统启动器重新打开'); + } + await run('systemctl', ['--user', 'disable', '--now', `${label}.service`]); + await rm(path); + await run('systemctl', ['--user', 'daemon-reload']); + } else if (platform === 'win32') { + const command = win32.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'schtasks.exe'); + const task = await run(command, ['/Query', '/TN', label, '/XML']).catch( + async (error: unknown) => { + if ((error as { code?: unknown }).code === 1) { + const powershell = win32.join( + env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + const { stdout } = await run(powershell, [ + '-NoProfile', + '-NonInteractive', + '-Command', + "$ErrorActionPreference='Stop'; ConvertTo-Json -InputObject @(Get-ScheduledTask -ErrorAction Stop | Select-Object TaskName,TaskPath) -Compress", + ]); + const tasks: unknown = JSON.parse(stdout); + if ( + !Array.isArray(tasks) || + !tasks.every( + (entry: unknown) => + entry && + typeof entry === 'object' && + 'TaskName' in entry && + typeof entry.TaskName === 'string' && + 'TaskPath' in entry && + typeof entry.TaskPath === 'string', + ) + ) + throw new Error('无法核实旧计划任务是否存在'); + if (!tasks.some((entry) => entry.TaskName === label && entry.TaskPath === '\\')) + return null; + } + throw error; + }, + ); + if (!task) continue; + const xml = new DOMParser().parseFromString(task.stdout, 'text/xml'); + const commands = Array.from(xml.getElementsByTagName('Command')); + if (!commands.some((entry) => /weq/i.test(entry.textContent ?? ''))) + throw new Error(`旧任务 ${label} 的配置不匹配,未移除`); + if (label !== 'weq-daemon-gui') await run(command, ['/End', '/TN', label]).catch(() => {}); + await run(command, ['/Delete', '/TN', label, '/F']); + } + } + await daemonStop(); + for (let attempt = 0; attempt < 20; attempt += 1) { + if (!(await pingDaemon())) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('旧 WeQ 后台尚未停止,请检查旧服务后重启 WeQ'); +} diff --git a/apps/desktop/src/main/main_window_lifecycle.ts b/apps/desktop/src/main/main_window_lifecycle.ts index 7eaad197e..122519d49 100644 --- a/apps/desktop/src/main/main_window_lifecycle.ts +++ b/apps/desktop/src/main/main_window_lifecycle.ts @@ -12,21 +12,22 @@ export function createMainWindowLifecycle(options: { canOpen(): boolean; getWindow(): W | null; setWindow(window: W | null): void; - createWindow(): W; + createWindow(showInitially: boolean): W; attachWindow(window: W): void; -}): { open(): W | null } { +}): { open(options?: { show?: boolean }): W | null } { return { - open() { + open({ show = true } = {}) { if (!options.canOpen()) return null; const current = options.getWindow(); if (current && !current.isDestroyed()) { + if (!show) return current; if (current.isMinimized()) current.restore(); current.show(); current.focus(); return current; } - const window = options.createWindow(); + const window = options.createWindow(show); options.setWindow(window); window.once('closed', () => { // A late close from an old window must not erase its replacement. diff --git a/apps/desktop/src/main/mcp/account_scope.ts b/apps/desktop/src/main/mcp/account_scope.ts index bf02112b5..4c12d3ffd 100644 --- a/apps/desktop/src/main/mcp/account_scope.ts +++ b/apps/desktop/src/main/mcp/account_scope.ts @@ -1,8 +1,21 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -type ToolRunner = (name: string, args: Record) => Promise; +export interface ToolExecutionOptions { + permissionMode?: 'sandbox' | 'unrestricted'; + signal?: AbortSignal; +} +type ToolRunner = ( + name: string, + args: Record, + options?: ToolExecutionOptions, +) => Promise; const accountScope = new AsyncLocalStorage<() => void>(); +const executionScope = new AsyncLocalStorage(); + +export function toolExecutionOptions(): ToolExecutionOptions { + return executionScope.getStore() ?? {}; +} const closedAccounts = new WeakSet(); export function invalidateAccountTools(account: object | null): void { @@ -23,11 +36,13 @@ export function bindAccountTools( if (closedAccounts.has(account) || currentAccount() !== account) throw new Error('助手任务所属账号已关闭或切换。'); }; - return (name, args) => - accountScope.run(check, async () => { - check(); - const result = await run(name, args); - check(); - return result; - }); + return (name, args, options) => + executionScope.run(options ?? {}, () => + accountScope.run(check, async () => { + check(); + const result = await run(name, args, options); + check(); + return result; + }), + ); } diff --git a/apps/desktop/src/main/mcp/external.ts b/apps/desktop/src/main/mcp/external.ts index da0edbb69..14e701818 100644 --- a/apps/desktop/src/main/mcp/external.ts +++ b/apps/desktop/src/main/mcp/external.ts @@ -23,6 +23,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { getLogger, logErrorContext } from '@weq/service'; import type { OpenAiToolSpec } from './openai_tools'; +import { externalMcpHttpOptions } from './external_http'; const logger = getLogger().child({ scope: 'mcp-external' }); @@ -245,12 +246,15 @@ export class ExternalMcpHub { } /** Run a namespaced external tool. Throws if unknown / server unreachable. */ - async run(name: string, args: Record): Promise { + async run(name: string, args: Record, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); const ref = this.toolIndex.get(name); if (!ref) throw new Error(`未知的外部 MCP 工具:${name}`); const conn = this.conns.get(ref.server); if (!conn?.client) throw new Error(`外部 MCP 服务器「${ref.server}」未连接`); - const res = (await conn.client.callTool({ name: ref.original, arguments: args })) as { + const res = (await conn.client.callTool({ name: ref.original, arguments: args }, undefined, { + signal, + })) as { isError?: boolean; content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown; @@ -308,19 +312,15 @@ export class ExternalMcpHub { private async connect(conn: Conn): Promise { const { spec } = conn; const url = new URL(spec.url); - const requestInit = spec.headers ? { headers: spec.headers } : undefined; + const transportOptions = externalMcpHttpOptions(url, spec.headers); try { const client = new Client({ name: 'weq-assistant', version: '0.1.0' }, { capabilities: {} }); // Streamable HTTP first; fall back to legacy SSE on connect failure. try { - await client.connect( - new StreamableHTTPClientTransport(url, requestInit ? { requestInit } : undefined), - ); + await client.connect(new StreamableHTTPClientTransport(url, transportOptions)); } catch (httpErr) { try { - await client.connect( - new SSEClientTransport(url, requestInit ? { requestInit } : undefined), - ); + await client.connect(new SSEClientTransport(url, transportOptions)); } catch { throw httpErr; // surface the primary (HTTP) error } diff --git a/apps/desktop/src/main/mcp/external_http.ts b/apps/desktop/src/main/mcp/external_http.ts new file mode 100644 index 000000000..574b666ca --- /dev/null +++ b/apps/desktop/src/main/mcp/external_http.ts @@ -0,0 +1,20 @@ +/** HTTP 和 SSE 共用来源约束;重定向需用户改填最终端点,不能携带密钥跟随。 */ +export function externalMcpHttpOptions(url: URL, headers?: Record) { + if (url.protocol !== 'http:' && url.protocol !== 'https:') + throw new Error('MCP 端点仅支持 HTTP/HTTPS'); + const origin = url.origin; + const fetchWithoutRedirects: typeof fetch = (input, init) => { + const target = new URL( + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, + ); + if (target.origin !== origin) + return Promise.reject(new Error('已阻止 MCP 跨来源请求,请配置最终端点 URL。')); + return fetch(input, { ...init, redirect: 'error' }); + }; + return { + requestInit: { headers, redirect: 'error' as const }, + fetch: fetchWithoutRedirects, + eventSourceInit: { fetch: fetchWithoutRedirects }, + redirectPolicy: 'same-origin' as const, + }; +} diff --git a/apps/desktop/src/main/mcp/js_sandbox.ts b/apps/desktop/src/main/mcp/js_sandbox.ts index 3924e7155..646503aad 100644 --- a/apps/desktop/src/main/mcp/js_sandbox.ts +++ b/apps/desktop/src/main/mcp/js_sandbox.ts @@ -1,548 +1,189 @@ -/** - * 助手专用 JS 沙箱。 - * - * 为什么需要它:`AI_TOOLS` 里的工具都是「一次调用、一个结果」的原子能力, - * 遇到**批量 / 聚合 / 跨工具**的活(「把上百个群逐个核对一遍」「按角色筛人」 - * 「把几份结果 join 起来统计」),模型只能一个个工具硬调,既慢又容易在参数上 - * 反复试错、还受「一轮最多 24 步」限制。给它一块能写代码的地方,这些活一段 - * 脚本就干完了——而且脚本能自己看到中间数据,不必把每一步都塞回上下文。 - * - * 设计要点: - * - * - **唯一出口是 `callTool`。** 沙箱里没有 `require` / `import` / `fs` / - * `fetch` / `process`,也没有 Node 的任何全局对象;要拿数据只能回调进 - * `callTool(name, args)`,落到与助手完全相同的工具执行路径上。于是**能力的 - * 边界和审计点都只有一处**:沙箱能做的事 = 助手本来就能做的事,不会凭空多出 - * 网络、文件系统之类的口子。 - * - **跑在 worker_threads 里,而不是主进程的 vm。** 模型写的代码里一个 - * `while (true)` 就能冻住整个 Electron 主进程;`node:vm` 的 `timeout` 只 - * 管得住同步段,`await` 之后的段落它管不着。放进 worker 后,超时可以直接 - * `terminate()` 硬杀,最坏也只是这段脚本失败,应用照常。 - * - **vm 只是护栏,不是安全边界。** Node 的 `vm` 不承诺隔离恶意代码;这里的 - * 定位是「防手滑、防死循环、防污染宿主全局」,配合 `codeGeneration.strings - * = false` 关掉 `eval` / `Function` 这条常见逃逸链。真正跑进来的代码是本机 - * 模型生成的、用户自己的账号数据——不是不可信输入。 - * - **Worker 源码用字符串 + `eval: true`。** 与 `@weq/service` 的 - * `db_decrypt.ts` 同款:主进程是 electron-vite 打包产物,独立 worker 文件要 - * 额外配构建才能进包,字符串 worker 最省事也最不容易断。 - */ - +/** 模型代码默认在 QuickJS/WASM 中解释执行;任意权限必须由宿主显式传入。 */ +import { createRequire } from 'node:module'; import { Worker } from 'node:worker_threads'; +import { SANDBOX_WORKER_SOURCE } from './sandbox_worker_source'; -/** 代码正文长度上限(防止一整坨贴进来)。 */ -const MAX_CODE_CHARS = 20_000; - -/** 默认执行时限。 */ export const JS_SANDBOX_DEFAULT_TIMEOUT_MS = 15_000; -/** - * 执行时限上限。**必须明显小于助手侧的单工具超时**(`assistant.ts` 的 - * `TOOL_TIMEOUT_MS` = 60s):外层先超时的话,这个 worker 就成了没人回收的孤儿。 - */ export const JS_SANDBOX_MAX_TIMEOUT_MS = 45_000; - -/** 单个脚本最多调用多少次工具。 */ +export const JS_SANDBOX_MAX_ARGUMENT_CHARS = 200_000; +const MAX_CODE_CHARS = 20_000; const MAX_TOOL_CALLS = 40; -/** `console` 收集上限。 */ -const MAX_LOG_LINES = 200; -const MAX_LOG_CHARS = 20_000; -/** 单次工具结果允许回灌进沙箱的字符上限。 */ const MAX_CALL_RESULT_CHARS = 200_000; -/** 单个失败调用的原因长度上限(回灌给模型看,不必太长)。 */ -const MAX_CALL_ERROR_CHARS = 400; -/** - * 脚本结束后为「还在飞的 callTool」多等多久。 - * 不白等太久:等到了就能报出真实错误/拿到 logs;等不到就告诉模型「你没 await」。 - */ -const PENDING_GRACE_MS = 3_000; -/** 最终结果字符上限(助手回灌给模型时另有 8000 的上限,这里给宽些,保住 trace)。 */ -const MAX_RESULT_CHARS = 40_000; -/** 沙箱执行工具的方式:与助手共用同一个入口(内置 + 外部 MCP 都能调)。 */ -export type SandboxToolRunner = (name: string, args: Record) => Promise; +export type SandboxToolRunner = ( + name: string, + args: Record, + signal?: AbortSignal, +) => Promise; export interface JsSandboxCall { name: string; ok: boolean; ms: number; - /** 失败时的原因(callTool 抛出来的那句话),让模型知道该改什么。 */ error?: string; + /** 超时或停止不等于已发出的宿主操作已回滚。 */ + state?: 'running' | 'completed' | 'unknown'; } export interface JsSandboxResult { - /** - * 脚本跑完且没出问题为 true;语法错/运行错/超时/调用超限/**脚本没跑完就结束**均为 false。 - * - * 「脚本结束了但还有 callTool 在飞」也算 false:那种情况 result 往往是 null, - * 报成功就变成了「查完了,没结果」的假阴性——实测助手就踩过这个坑。 - */ ok: boolean; - /** 脚本 `return` 的值(JSON 化后)。没 return 时是 null,此时看 logs。 */ result: unknown; - /** 出错原因(ok=false 时)。 */ error?: string; - /** `console.*` 的输出,按行。 */ logs: string[]; - /** 脚本里每次 `callTool` 的轨迹,成功失败与耗时。 */ calls: JsSandboxCall[]; - /** 实际耗时(毫秒)。 */ ms: number; - /** 结果被截断时的说明。 */ truncated?: string; - /** 给模型看的提示(没 return、有失败调用…),不是错误本身。 */ notes?: string[]; - /** 脚本结束时仍在飞的 callTool 数。 */ unawaited?: number; } -/** - * `workerData` 的载体;worker 源码里按字段名取,改动时两边一起改。 - * 注意源码是纯 JS 字符串,字段名保持一致即可,不做类型共享。 - * - * 这里**只有执行侧需要的东西**:工具次数上限、调用轨迹由主线程掌握(它才是唯一 - * 知道每次调用成败的地方),worker 只管跑代码、报结果。 - */ -interface SandboxWorkerData { - code: string; - syncTimeoutMs: number; - maxLogLines: number; - maxLogChars: number; - maxResultChars: number; - pendingGraceMs: number; -} - -/** - * Worker 源码。约束:**只能用单引号与字符串拼接**——它整体是一个模板字面量, - * 里面出现反引号或 `${` 会被外层提前求值(踩过一次)。 - */ -const WORKER_SOURCE = ` -const { parentPort, workerData } = require('node:worker_threads'); -const vm = require('node:vm'); -const util = require('node:util'); - -const cfg = workerData; -const logs = []; -const pending = new Map(); -let seq = 0; -let logChars = 0; - -function post(msg) { - try { parentPort.postMessage(msg); } catch (e) { /* 传不出去只能放弃 */ } -} - -// 序列化成 JSON 文本再回主线程:跨 realm 的值不走结构化克隆,避免遇到函数/循环引用时直接抛。 -function toJson(value) { - try { - return JSON.stringify(value, function (k, v) { - return typeof v === 'bigint' ? v.toString() : v; - }); - } catch (e) { - try { return JSON.stringify(String(value)); } catch (e2) { return '"<无法序列化>"'; } - } -} - -function fmt(args) { - return args.map(function (a) { - if (typeof a === 'string') return a; - try { return util.inspect(a, { depth: 5, maxArrayLength: 80, breakLength: 120, compact: true }); } - catch (e) { return String(a); } - }).join(' '); -} - -function pushLog(level, args) { - if (logs.length >= cfg.maxLogLines || logChars >= cfg.maxLogChars) return; - const line = '[' + level + '] ' + fmt(args); - logChars += line.length; - logs.push(line); -} - -// 脚本创建但还没落定的异步工作(callTool / sleep)。脚本 resolve 时若这个集合非空, -// 说明有 fire-and-forget 的分支没有 await —— 那是「脚本没跑完」,不能报成功。 -const outstanding = new Set(); -let sleepers = 0; - -function track(promise) { - outstanding.add(promise); - const drop = function () { outstanding.delete(promise); }; - // 两个分支都挂上:顺便替它们接住拒绝,不让它变成没人管的 unhandledRejection。 - promise.then(drop, drop); - return promise; -} - -function callTool(name, args) { - let resolveFn; - let rejectFn; - const promise = new Promise(function (resolve, reject) { - resolveFn = resolve; - rejectFn = reject; - }); - if (typeof name !== 'string' || !name) { - rejectFn(new Error('callTool(name, args):name 必须是非空字符串(工具名)')); - return track(promise); - } - const id = ++seq; - pending.set(id, { resolve: resolveFn, reject: rejectFn }); - post({ kind: 'call', id: id, name: name, args: args && typeof args === 'object' ? args : {} }); - return track(promise); -} - -// 没被 await、也没人 catch 的异步错误(典型是 main() 没 await): -// 默认会变成静默的 unhandledRejection,脚本看似成功、其实什么都没算出来。 -const asyncErrors = []; - -function recordAsyncError(reason) { - if (asyncErrors.length >= 5) return; - const message = reason && reason.message ? reason.message : String(reason); - asyncErrors.push(message + locate(reason)); -} - -process.on('unhandledRejection', recordAsyncError); -process.on('uncaughtException', function (err) { - recordAsyncError(err); - finish(false, null, err, 0); -}); - -// 报错尽量带上用户代码的行号(wrapper 前缀不含换行,所以行号与用户写的行一一对应)。 -// 用 indexOf 手抠而不是正则:这整段源码是外层模板字面量,正则里的反斜杠会被提前吃掉一层, -// 写 /assistant-sandbox.js:(d+)/ 实际会生成 (d+),静默匹配不上。 -function locate(err) { - const stack = err && err.stack ? String(err.stack) : ''; - // 语法错的 stack 头是 'assistant-sandbox.js:2',运行错里则是 'at assistant-sandbox.js:1:26'。 - const marker = 'assistant-sandbox.js:'; - const at = stack.indexOf(marker); - if (at < 0) return ''; - let i = at + marker.length; - let line = ''; - while (i < stack.length && stack[i] >= '0' && stack[i] <= '9') line += stack[i++]; - if (!line) return ''; - let col = ''; - if (stack[i] === ':') { - let j = i + 1; - while (j < stack.length && stack[j] >= '0' && stack[j] <= '9') col += stack[j++]; - } - return '(第 ' + line + ' 行' + (col ? ':' + col : '') + ')'; -} - -function finish(ok, value, error, unawaited, unawaitedDesc) { - let resultText = null; - let truncated = null; - let message = null; - const notes = []; - if (ok) { - resultText = toJson(value); - if (typeof resultText === 'string' && resultText.length > cfg.maxResultChars) { - resultText = resultText.slice(0, cfg.maxResultChars); - truncated = 'result 超过 ' + cfg.maxResultChars + ' 字符已截断'; - } - if (value === undefined) { - notes.push('脚本没有 return 值,result 是 null;要把结论带出来请显式 return。'); - } - } else { - const base = error && error.message ? error.message : String(error); - message = base + locate(error); - } - - // 两个都不算成功。两条信息都拼上:那个异步错误往往正是「没等完」的病因。 - if (asyncErrors.length) { - ok = false; - const head = '脚本里有未被处理的异步错误:' + asyncErrors[0]; - message = message ? message + '|' + head : head; - } - if (unawaited > 0) { - ok = false; - const hint = - '脚本在还有 ' + unawaited + ' 处异步工作(' + unawaitedDesc + ')没完成时就结束了' + - '(忘了 await,或者定义了 main() 却没 return main()?)。' + - 'result 为 null 不代表没有结果,是脚本根本没等你查完。'; - message = message ? message + '|' + hint : hint; - } - - post({ - kind: 'done', - ok: ok, - resultText: resultText, - error: message, - truncated: truncated, - notes: notes, - unawaited: unawaited, - logs: logs, - }); -} - -// 主线程回包统一走 text 字段:ok=true 时是结果 JSON,ok=false 时是错误信息。 -parentPort.on('message', function (msg) { - if (!msg || msg.kind !== 'callResult') return; - const p = pending.get(msg.id); - if (!p) return; - pending.delete(msg.id); - if (msg.ok) { - let value = null; - try { value = msg.text ? JSON.parse(msg.text) : null; } catch (e) { value = msg.text; } - p.resolve(value); - } else { - p.reject(new Error(msg.text || '工具调用失败')); - } -}); - -const consoleShim = {}; -['log', 'info', 'warn', 'error', 'debug'].forEach(function (level) { - consoleShim[level] = function () { - pushLog(level, Array.prototype.slice.call(arguments)); - }; -}); - -const sandbox = { - callTool: callTool, - sleep: function (ms) { - const n = Math.min(Math.max(Number(ms) || 0, 0), 10000); - sleepers += 1; - // 被 await 的 sleep 会在脚本 resolve 前就减回去,只有 fire-and-forget 的会被算成未完成。 - return track(new Promise(function (r) { - setTimeout(function () { sleepers -= 1; r(); }, n); - })); - }, - console: consoleShim, -}; - -// codeGeneration.strings = false:关掉这个上下文里的 eval / new Function, -// 掐掉最常见的一条从「普通对象」爬回宿主的链子。vm.Script 不受影响。 -const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } }); - -// 包一层 async IIFE 以支持顶层 await;用户代码从第 1 行开始,报错行号不会错位。 -const wrapped = '(async () => {' + cfg.code + '\\n})()'; - -// 脚本本体结束后,给还挂着的 callTool 一点时间把结果收回来(logs 更完整、失败原因也拿得到), -// 但不无限等——总的硬超时在主线程那边兜底。 -function settlePending(cb) { - const unawaited = outstanding.size; - const parts = []; - if (pending.size) parts.push('callTool × ' + pending.size); - if (sleepers) parts.push('sleep × ' + sleepers); - const desc = parts.join('、') || '未明了'; - let done = false; - const go = function () { - if (done) return; - done = true; - cb(unawaited, desc); - }; - if (unawaited === 0) { - // 即使没有在飞工作,也要放过一个事件循环轮次再收工:沙箱里可能还有没人管的 - // 异步拒绝在排队,立刻返回会让 worker 被主线程 terminate 掉,错误就静默丢了。 - setTimeout(go, 0); - return; - } - // 等它们落定(logs 更完整、失败原因也拿得到),但不无限等:grace 到就收工, - // 总的硬超时还在主线程那边兜底。 - Promise.allSettled(Array.from(outstanding)).then(go, go); - setTimeout(go, cfg.pendingGraceMs); -} - -try { - const script = new vm.Script(wrapped, { filename: 'assistant-sandbox.js' }); - const running = script.runInContext(context, { timeout: cfg.syncTimeoutMs }); - Promise.resolve(running).then( - function (value) { - settlePending(function (unawaited, desc) { finish(true, value, null, unawaited, desc); }); - }, - function (err) { finish(false, null, err, 0, null); }, - ); -} catch (err) { - finish(false, null, err, 0, null); -} -`; - -function clamp(value: number, min: number, max: number): number { - if (!Number.isFinite(value)) return min; - return Math.min(Math.max(value, min), max); +interface WorkerMessage { + kind: 'call' | 'done'; + id?: number; + name?: string; + args?: Record; + ok?: boolean; + resultText?: string; + error?: string; + logs?: string[]; + notes?: string[]; + unawaited?: number; + truncated?: string; } -/** - * 在沙箱里跑一段模型写的 JS。 - * - * @param code 脚本源码(可含顶层 `await`,用 `return` 给出结果)。 - * @param callTool 唯一的对外通道:脚本里 `callTool(name, args)` 最终执行它。 - * @param timeoutMs 执行时限,缺省 {@link JS_SANDBOX_DEFAULT_TIMEOUT_MS}。 - * - * **不抛异常**:语法错、运行错、超时、调用超限、以及「脚本没跑完就结束」都压成 - * `ok:false` 的结果返回——这样 `logs` / `calls` / `notes` 能一起回灌给模型,它自己 - * 看着报错改代码重试,而不是只拿到一句「执行失败」,更不是把 null 当成「查无结果」。 - */ export async function runJsSandbox( code: string, callTool: SandboxToolRunner, - timeoutMs: number = JS_SANDBOX_DEFAULT_TIMEOUT_MS, + timeoutMs = JS_SANDBOX_DEFAULT_TIMEOUT_MS, + options: { permissionMode?: 'sandbox' | 'unrestricted'; signal?: AbortSignal } = {}, ): Promise { const startedAt = Date.now(); - const fail = ( - error: string, - logs: string[] = [], - calls: JsSandboxCall[] = [], - ): JsSandboxResult => ({ + const calls: JsSandboxCall[] = []; + const fail = (error: string): JsSandboxResult => ({ ok: false, result: null, error, - logs, + logs: [], calls, ms: Date.now() - startedAt, }); - if (typeof code !== 'string' || !code.trim()) return fail('code 为空'); - if (code.length > MAX_CODE_CHARS) { - return fail(`code 过长(${code.length} 字符,上限 ${MAX_CODE_CHARS})`); - } - - const limit = clamp(timeoutMs, 1_000, JS_SANDBOX_MAX_TIMEOUT_MS); - const workerData: SandboxWorkerData = { - code, - syncTimeoutMs: limit, - maxLogLines: MAX_LOG_LINES, - maxLogChars: MAX_LOG_CHARS, - maxResultChars: MAX_RESULT_CHARS, - pendingGraceMs: PENDING_GRACE_MS, - }; - - const calls: JsSandboxCall[] = []; - - return await new Promise((resolve) => { + if (code.length > MAX_CODE_CHARS) return fail(`code 过长(上限 ${MAX_CODE_CHARS} 字符)`); + if (options.signal?.aborted) return fail('脚本已取消'); + const limit = Number.isFinite(timeoutMs) + ? Math.min(Math.max(timeoutMs, 1000), JS_SANDBOX_MAX_TIMEOUT_MS) + : JS_SANDBOX_DEFAULT_TIMEOUT_MS; + const controller = new AbortController(); + return new Promise((resolve) => { let settled = false; - const worker = new Worker(WORKER_SOURCE, { eval: true, workerData }); - + const worker = new Worker(SANDBOX_WORKER_SOURCE, { + eval: true, + workerData: { + code, + limit, + maxToolCalls: MAX_TOOL_CALLS, + maxArgumentChars: JS_SANDBOX_MAX_ARGUMENT_CHARS, + // 依赖保持外置,worker 与已打包主进程从同一安装位置解析 WASM。 + quickjsPath: createRequire(import.meta.url).resolve('quickjs-emscripten'), + unrestricted: options.permissionMode === 'unrestricted', + }, + resourceLimits: { maxOldGenerationSizeMb: 128, maxYoungGenerationSizeMb: 16 }, + }); const settle = (result: JsSandboxResult): void => { if (settled) return; settled = true; - clearTimeout(killTimer); - void worker.terminate(); // 正常结束也 terminate:worker 里可能还有未清的 timer 在挂着 - resolve(result); + clearTimeout(timer); + options.signal?.removeEventListener('abort', onAbort); + controller.abort(); + void worker.terminate(); + const snapshot = calls.map((call) => ({ + ...call, + state: call.state === 'running' ? ('unknown' as const) : call.state, + })); + const unknown = snapshot.filter((call) => call.state === 'unknown'); + resolve({ + ...result, + calls: snapshot, + ...(unknown.length + ? { + ok: false, + notes: [ + ...(result.notes ?? []), + '存在尚未确认终态的宿主操作,可能仍会完成;不要自动重试,请先核对实际结果。', + ], + } + : {}), + }); }; - - // 硬超时。vm 的 timeout 只管同步段,await 之后要靠这一步兜底。 - const killTimer = setTimeout(() => { - settle(fail(`脚本执行超过 ${limit}ms 已中止(可能有死循环或卡住的 await)`, [], calls)); - }, limit); - - /** - * 脚本里的一次 `callTool`。次数上限与调用轨迹都记在这里——只有主线程知道每次调用 - * 的成败,worker 那边看不到,所以策略留在这一侧。 - */ - const handleCall = async ( - id: number, - name: string, - args: Record, - ): Promise => { + const onAbort = (): void => settle(fail('脚本已取消;已发出的操作不保证回滚')); + const timer = setTimeout( + () => settle(fail(`脚本执行超过 ${limit}ms 已中止;已发出的操作不保证回滚`)), + limit, + ); + options.signal?.addEventListener('abort', onAbort, { once: true }); + if (options.signal?.aborted) onAbort(); + const handleCall = async (msg: WorkerMessage): Promise => { + if (settled) return; + const name = String(msg.name); const reply = (ok: boolean, text: string): void => { - if (settled) return; - worker.postMessage({ kind: 'callResult', id, ok, text }); + if (!settled) worker.postMessage({ kind: 'callResult', id: msg.id, ok, text }); }; - // 先记账再执行:并行的 callTool 也要一起算进上限。 - if (calls.length >= MAX_TOOL_CALLS) { - calls.push({ name, ok: false, ms: 0 }); + if (calls.length >= MAX_TOOL_CALLS || name === 'run_js') { reply( false, - `本次脚本调用工具次数已达上限 ${MAX_TOOL_CALLS} 次。要处理更多数据就拆成多轮对话,每轮少调几次。`, + name === 'run_js' ? '禁止递归调用 run_js' : `工具调用超过 ${MAX_TOOL_CALLS} 次上限`, ); return; } - const entry: JsSandboxCall = { name, ok: false, ms: 0 }; - calls.push(entry); - // 禁止套娃:沙箱里再开沙箱会变成 worker 递归,直接把这条路封掉。 - if (name === 'run_js') { - reply( - false, - 'run_js 不能在沙箱内再调用(会递归开 worker)。要分段处理就把它拆成多轮主对话。', - ); - return; - } - const t0 = Date.now(); + const call: JsSandboxCall = { name, ok: false, ms: 0, state: 'running' }; + calls.push(call); + const start = Date.now(); try { - const value = await callTool(name, args ?? {}); - let text = JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)); - if (text === undefined) text = 'null'; - if (text.length > MAX_CALL_RESULT_CHARS) { - // 从中间截断会得到非法 JSON,所以明确包一层:让脚本看得见「你拿到的是预览」。 + const value = await callTool(name, msg.args ?? {}, controller.signal); + let text = + JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? String(v) : v)) ?? 'null'; + if (text.length > MAX_CALL_RESULT_CHARS) text = JSON.stringify({ __truncated: true, - note: `工具结果超过 ${MAX_CALL_RESULT_CHARS} 字符,这里只是前缀预览;请缩小查询范围(加 limit / 分批)再调。`, preview: text.slice(0, MAX_CALL_RESULT_CHARS), }); - } - entry.ok = true; - entry.ms = Date.now() - t0; + call.ok = true; reply(true, text); } catch (error) { - entry.ms = Date.now() - t0; - // 原因存进轨迹:脚本自己 catch 了也好,没 catch 也好,模型都能从 calls[].error - // 看到「到底为什么失败」,而不是只拿到一句「有一次调用失败了」。 - entry.error = (error instanceof Error ? error.message : String(error)).slice( - 0, - MAX_CALL_ERROR_CHARS, - ); - reply(false, entry.error); + call.error = (error instanceof Error ? error.message : String(error)).slice(0, 400); + reply(false, call.error); + } finally { + call.ms = Date.now() - start; + call.state = 'completed'; } }; - - worker.on('message', (msg: SandboxWorkerMessage) => { - if (!msg || typeof msg !== 'object') return; + worker.on('message', (msg: WorkerMessage) => { + if (settled || !msg || typeof msg !== 'object') return; if (msg.kind === 'call') { - void handleCall(Number(msg.id), String(msg.name), msg.args ?? {}); + void handleCall(msg); return; } if (msg.kind === 'done') { - const notes = [...(msg.notes ?? [])]; - // 把失败调用汇总成一条提示:模型最容易忽略的地方,明着写一遍。 - const failed = calls.filter((c) => !c.ok); - if (failed.length) { - const detail = failed - .slice(0, 3) - .map((c) => `${c.name}:${c.error ?? '原因未知'}`) - .join(';'); - notes.push( - `本次有 ${failed.length} 次工具调用失败——${detail}${failed.length > 3 ? '(其余略)' : ''}`, - ); + let result: unknown = null; + try { + result = msg.resultText ? JSON.parse(msg.resultText) : null; + } catch { + result = msg.resultText; } settle({ ok: msg.ok === true, - result: msg.resultText ? safeParse(msg.resultText) : null, - error: msg.error ?? undefined, + result, + error: msg.error, logs: msg.logs ?? [], calls, ms: Date.now() - startedAt, - truncated: msg.truncated ?? undefined, - notes: notes.length ? notes : undefined, - unawaited: msg.unawaited || undefined, + notes: msg.notes, + unawaited: msg.unawaited, + truncated: msg.truncated, }); } }); - - worker.on('error', (error) => { - settle(fail(`沙箱进程异常:${error.message}`, [], calls)); - }); - - // 没发 done 就退出 = 崩了(进程级错误,如 OOM)。已 settle 时这里是 no-op。 - worker.on('exit', (code) => { - settle(fail(`沙箱进程意外退出(code ${code})`, [], calls)); - }); + worker.on('error', (error) => settle(fail(`脚本进程异常:${error.message}`))); + worker.on('exit', (exitCode) => settle(fail(`脚本进程意外退出(code ${exitCode})`))); }); } - -/** worker → 主线程的消息形状(worker 源码是纯 JS,这里只做描述性声明)。 */ -interface SandboxWorkerMessage { - kind?: 'call' | 'done'; - id?: number; - name?: string; - args?: Record; - ok?: boolean; - resultText?: string | null; - error?: string | null; - truncated?: string | null; - notes?: string[]; - unawaited?: number; - logs?: string[]; -} - -/** 解析 worker 回传的结果文本;解析不了就原样返回,别让沙箱自己抛错。 */ -function safeParse(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return text; - } -} diff --git a/apps/desktop/src/main/mcp/openai_tools.ts b/apps/desktop/src/main/mcp/openai_tools.ts index a08934e9d..47d466c31 100644 --- a/apps/desktop/src/main/mcp/openai_tools.ts +++ b/apps/desktop/src/main/mcp/openai_tools.ts @@ -11,6 +11,9 @@ import type { z } from 'zod'; import { getExternalMcpHub } from './external'; import { AI_TOOLS } from './tools'; import { assertAccountToolScope } from './account_scope'; +import { toolExecutionOptions, type ToolExecutionOptions } from './account_scope'; +import { getAppContext } from '../context/app_context'; +import { isSandboxTool } from './tool_permissions'; export interface OpenAiToolSpec { type: 'function'; @@ -84,13 +87,32 @@ function objectToParameters(obj: z.ZodObject): Record ({ +export function aiToolSpecs(options: ToolExecutionOptions = {}): OpenAiToolSpec[] { + const unrestricted = + options.permissionMode === 'unrestricted' && + getAppContext().services?.assistant.getConfig().permissionMode === 'unrestricted'; + return AI_TOOLS.filter((t) => unrestricted || isSandboxTool(t.name)).map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: objectToParameters(t.input) }, })); } +/** 沙箱不连接外部服务器取工具列表,避免未授权的工具和连接进入本轮。 */ +export async function assistantToolSpecs( + options: ToolExecutionOptions = {}, +): Promise { + options.signal?.throwIfAborted(); + const specs = aiToolSpecs(options); + if ( + options.permissionMode !== 'unrestricted' || + getAppContext().services?.assistant.getConfig().permissionMode !== 'unrestricted' + ) + return specs; + const external = await getExternalMcpHub().specs(); + options.signal?.throwIfAborted(); + return [...specs, ...external]; +} + /** 把 zod 校验失败压成一行可读信息(喂回模型,让它自己改参数重试)。 */ function formatIssues(error: z.ZodError): string { return error.issues @@ -133,8 +155,15 @@ export async function runAiTool(name: string, args: Record): Pr export async function runAssistantTool( name: string, args: Record, + options: ToolExecutionOptions = toolExecutionOptions(), ): Promise { assertAccountToolScope(); - if (name.startsWith('mcp__')) return getExternalMcpHub().run(name, args); + options.signal?.throwIfAborted(); + const unrestricted = + options.permissionMode === 'unrestricted' && + getAppContext().services?.assistant.getConfig().permissionMode === 'unrestricted'; + if (!unrestricted && !isSandboxTool(name)) + throw new Error(`沙箱模式不允许工具 ${name};需要在助手设置中明确启用任意权限。`); + if (name.startsWith('mcp__')) return getExternalMcpHub().run(name, args, options.signal); return runAiTool(name, args); } diff --git a/apps/desktop/src/main/mcp/sandbox_worker_source.ts b/apps/desktop/src/main/mcp/sandbox_worker_source.ts new file mode 100644 index 000000000..d2d925081 --- /dev/null +++ b/apps/desktop/src/main/mcp/sandbox_worker_source.ts @@ -0,0 +1,135 @@ +/** 字符串 worker 避免构建产物寻址差异;宿主与解释器之间只传 JSON 文本。 */ +export const SANDBOX_WORKER_SOURCE = String.raw` +const { parentPort, workerData: cfg } = require('node:worker_threads'); +const pending = new Map(); +const logs = []; +let seq = 0; +let logChars = 0; +let finished = false; +let vm; +const deadline = Date.now() + cfg.limit; +const stringify = (value) => JSON.stringify(value, (_k, v) => typeof v === 'bigint' ? String(v) : v); +function log(level, text) { + if (logs.length >= 200 || logChars >= 20000) return; + const line = ('[' + level + '] ' + text).slice(0, 20000 - logChars); + logs.push(line); logChars += line.length; +} +function request(name, text) { + if (typeof name !== 'string' || !name || name.length > 128) return Promise.reject(new Error('工具名必须是 1-128 字符的字符串')); + if (typeof text !== 'string' || text.length > cfg.maxArgumentChars) return Promise.reject(new Error('工具参数超过 ' + cfg.maxArgumentChars + ' 字符上限')); + if (seq >= cfg.maxToolCalls) return Promise.reject(new Error('工具调用超过 ' + cfg.maxToolCalls + ' 次上限')); + let args; + try { args = JSON.parse(text); } catch { return Promise.reject(new Error('工具参数必须是 JSON 对象')); } + if (!args || typeof args !== 'object' || Array.isArray(args)) return Promise.reject(new Error('工具参数必须是 JSON 对象')); + return new Promise((resolve, reject) => { + const id = ++seq; + pending.set(id, { resolve, reject }); + parentPort.postMessage({ kind: 'call', id, name, args }); + }); +} +parentPort.on('message', (msg) => { + if (!msg || msg.kind !== 'callResult') return; + const call = pending.get(msg.id); + if (!call) return; + pending.delete(msg.id); + if (msg.ok) call.resolve(msg.text); else call.reject(new Error(msg.text)); +}); +function finish(ok, resultText, error, unawaited = 0) { + if (finished) return; + finished = true; + let truncated; + if (resultText && resultText.length > 40000) { + resultText = stringify({ preview: resultText.slice(0, 40000) }); + truncated = 'result 超过 40000 字符已截断'; + } + parentPort.postMessage({ kind: 'done', ok: ok && !unawaited, resultText, error, logs, unawaited, truncated, + notes: unawaited ? ['脚本结束时仍有异步工作,请 await 所有 callTool / sleep。'] : undefined }); +} +process.on('unhandledRejection', (error) => finish(false, null, String(error))); +async function main() { + if (cfg.unrestricted) { + // 这是用户明确授权的宿主执行模式,不宣称隔离,保留 Node 全部能力。 + const outstanding = new Set(); + const track = (promise) => { + outstanding.add(promise); + promise.then(() => outstanding.delete(promise), () => outstanding.delete(promise)); + return promise; + }; + const callTool = (name, args = {}) => track(request(name, stringify(args)).then(JSON.parse)); + const sleep = (ms) => track(new Promise(r => setTimeout(r, Math.min(Math.max(Number(ms) || 0, 0), 10000)))); + const consoleShim = Object.fromEntries(['log', 'info', 'warn', 'error', 'debug'].map(level => [level, (...args) => log(level, args.map(a => typeof a === 'string' ? a : stringify(a)).join(' '))])); + const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; + const value = await new AsyncFunction('callTool', 'sleep', 'console', 'require', cfg.code)(callTool, sleep, consoleShim, require); + const unawaited = outstanding.size; + if (unawaited) await Promise.race([Promise.allSettled([...outstanding]), new Promise(r => setTimeout(r, 3000))]); + finish(true, stringify(value) ?? 'null', undefined, unawaited); return; + } + const { getQuickJS } = require(cfg.quickjsPath); + const QuickJS = await getQuickJS(); + vm = QuickJS.newContext(); + vm.runtime.setMemoryLimit(32 * 1024 * 1024); + vm.runtime.setMaxStackSize(256 * 1024); + vm.runtime.setInterruptHandler(() => Date.now() >= deadline); + function pump() { + if (finished) return; + const jobs = vm.runtime.executePendingJobs(); + if (jobs.error) { + const error = vm.dump(jobs.error); jobs.error.dispose(); + finish(false, null, stringify(error)); + } + } + function bridge(name, work) { + const handle = vm.newFunction(name, (...args) => { + const deferred = vm.newPromise(); + let promise; + try { promise = work(...args); } catch (error) { promise = Promise.reject(error); } + Promise.resolve(promise).then( + text => { + if (finished) return; + const value = vm.newString(text); deferred.resolve(value); value.dispose(); + deferred.dispose(); pump(); + }, + error => { + if (finished) return; + const value = vm.newError(error instanceof Error ? error.message : String(error)); + deferred.reject(value); value.dispose(); deferred.dispose(); pump(); + }, + ); + return deferred.handle; + }); + vm.setProp(vm.global, name, handle); handle.dispose(); + } + bridge('__call', (name, args) => request(vm.getString(name), vm.getString(args))); + bridge('__sleep', (ms) => new Promise(resolve => setTimeout(() => resolve('null'), Math.min(Math.max(vm.getNumber(ms) || 0, 0), 10000)))); + const logger = vm.newFunction('__log', (level, text) => { log(vm.getString(level), vm.getString(text)); }); + vm.setProp(vm.global, '__log', logger); logger.dispose(); + const setup = vm.evalCode( + 'globalThis.__outstanding = 0; globalThis.__unawaited = 0;' + + 'const track = async (p) => { __outstanding++; try { return await p; } finally { __outstanding--; } };' + + 'globalThis.callTool = (name, args = {}) => track(__call(String(name), JSON.stringify(args))).then(JSON.parse);' + + 'globalThis.sleep = ms => track(__sleep(Number(ms)));' + + 'globalThis.console = Object.fromEntries(["log","info","warn","error","debug"].map(level => [level, (...args) => __log(level, args.map(x => typeof x === "string" ? x : JSON.stringify(x)).join(" "))]));' + ); + vm.unwrapResult(setup).dispose(); + const evaluated = vm.evalCode('(async () => {' + cfg.code + '\n})().then(value => { __unawaited = __outstanding; return JSON.stringify(value, (_k, v) => typeof v === "bigint" ? String(v) : v) ?? "null"; })', 'assistant-sandbox.js'); + if (evaluated.error) { + const error = vm.dump(evaluated.error); evaluated.error.dispose(); + finish(false, null, error.message || stringify(error)); return; + } + const promiseHandle = evaluated.value; + const resolved = vm.resolvePromise(promiseHandle); + pump(); + const result = await resolved; + promiseHandle.dispose(); + if (result.error) { + const error = vm.dump(result.error); result.error.dispose(); + finish(false, null, error.message || stringify(error)); return; + } + const text = vm.getString(result.value); result.value.dispose(); + const count = vm.getProp(vm.global, '__unawaited'); + const unawaited = vm.getNumber(count); count.dispose(); + if (unawaited) await new Promise(resolve => setTimeout(resolve, 30)); + finish(true, text, unawaited ? '脚本没有等待全部异步工作' : undefined, unawaited); +} +main().catch(error => finish(false, null, error.message || String(error))); +`; diff --git a/apps/desktop/src/main/mcp/server.ts b/apps/desktop/src/main/mcp/server.ts index 14535a39e..dc45cf846 100644 --- a/apps/desktop/src/main/mcp/server.ts +++ b/apps/desktop/src/main/mcp/server.ts @@ -10,8 +10,7 @@ * - stops on account switch / logout / app quit. * * Security: bound to loopback only, every request must carry - * `Authorization: Bearer `. Most tools are read-only; the deliberately - * advanced `execute_sql` / `decrypt_database` tools may write (see tools.ts). + * `Authorization: Bearer `;仅注册显式审核过的只读工具。 * * Transport is stateless — a fresh `McpServer` + transport per request — which * is the simplest correct shape for a single local client and avoids any @@ -23,6 +22,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { getLogger, logErrorContext } from '@weq/service'; import { AI_TOOLS } from './tools'; +import { isReadOnlyTool } from './tool_permissions'; const logger = getLogger().child({ scope: 'mcp-server' }); @@ -52,7 +52,7 @@ function buildMcpServer(): McpServer { const server = new McpServer({ name: 'weq', version: '0.1.0' }); for (const t of AI_TOOLS) { // 外部 MCP 面板保持严格只读:带副作用的工具(assistantOnly)只给内置助手用。 - if (t.assistantOnly) continue; + if (t.assistantOnly || !isReadOnlyTool(t.name)) continue; server.registerTool( t.name, { description: t.description, inputSchema: t.input.shape }, diff --git a/apps/desktop/src/main/mcp/tool_permissions.ts b/apps/desktop/src/main/mcp/tool_permissions.ts new file mode 100644 index 000000000..a33c4757b --- /dev/null +++ b/apps/desktop/src/main/mcp/tool_permissions.ts @@ -0,0 +1,67 @@ +/** 显式审核过的只读能力;新工具默认拒绝,不能用名称前缀推断权限。 */ +const READ_ONLY_TOOLS = new Set([ + 'search_messages', + 'search_in_conversation', + 'find_people_who_mentioned', + 'list_conversations', + 'get_messages', + 'list_groups', + 'list_buddies', + 'search_buddies', + 'search_groups', + 'get_self_profile', + 'find_contact', + 'list_group_members', + 'list_friends_by_intimacy', + 'get_user_profile', + 'get_group_info', + 'get_group_essence', + 'get_group_bulletins', + 'list_user_groups', + 'get_buddy_analytics', + 'inspect_timeline', + 'rank_friends_by_activity', + 'rank_my_groups_by_activity', + 'get_group_activity', + 'get_daily_digest', + 'get_period_overview', + 'compare_periods', + 'get_messages_by_date', + 'get_anti_recall_status', + 'list_recalled_messages', + 'list_deleted_messages', + 'get_forward_messages', + 'get_message_details', + 'get_ocr_status', + 'list_dataline_conversations', + 'get_dataline_messages', + 'list_guild_direct_sessions', + 'get_guild_direct_messages', + 'list_collections', + 'decode_blob', + 'list_databases', + 'list_db_tables', + 'get_db_columns', + 'get_peer_stats', + 'get_group_honor', + 'get_group_albums', + 'get_qzone_profile', + 'get_friend_dress', + 'get_self_dress', + 'get_friend_mutual_mark', + 'get_dress_mall', + 'get_dress_resource_url', + 'search_market_emoji', + 'get_market_pack_detail', + 'decode_redbag_packet', + 'get_red_bag_passwords', + 'get_red_bag_detail', +]); + +export function isReadOnlyTool(name: string): boolean { + return READ_ONLY_TOOLS.has(name); +} + +export function isSandboxTool(name: string): boolean { + return name === 'run_js' || isReadOnlyTool(name); +} diff --git a/apps/desktop/src/main/mcp/tools.ts b/apps/desktop/src/main/mcp/tools.ts index d6b7d2e68..1ef2cd5f5 100644 --- a/apps/desktop/src/main/mcp/tools.ts +++ b/apps/desktop/src/main/mcp/tools.ts @@ -17,6 +17,7 @@ import { homedir } from 'node:os'; import { isAbsolute, resolve } from 'node:path'; import { z } from 'zod'; import { getAppContext, type AccountServices } from '../context/app_context'; +import { fullModeAllowed } from '../context/full_mode'; import { assertAccountToolScope } from './account_scope'; import { classifyChatType, @@ -119,7 +120,7 @@ function onlinePid(): number { if (!record?.qqOnline || !record.qqPid) { throw new Error('需要先登录该账号的 QQ 客户端(本工具要走在线实例)。'); } - if (ctx.bootstrap?.userConfig.getSettings().autoAttachQq === false) { + if (!fullModeAllowed(ctx)) { throw new Error('已开启完全离线模式(自动读取 QQ 内存 已关闭),本工具不可用。'); } return record.qqPid; @@ -144,7 +145,7 @@ function currentUin(): string { function ocrOnlineBlocker(): string | null { const record = services().accountConfig.getRecord(); if (!record?.qqOnline || !record.qqPid) return '该账号的 QQ 未在线,走不了在线识别。'; - if (toolContext().bootstrap?.userConfig.getSettings().autoAttachQq === false) { + if (!fullModeAllowed(toolContext())) { return '已开启完全离线模式(自动读取 QQ 内存 已关闭),在线识别不可用。'; } const session = record.session; @@ -246,6 +247,9 @@ interface AiMsgLine { /** 稳定消息标识(仅 includeIds 时附上,供 get_message_details 回查)。 */ msgId?: string; msgSeq?: string; + /** 原始 Unix 秒与稳定发送者,供只读消费者使用,不依赖 SQL 工具。 */ + sendTime?: string; + senderUid?: string; /** * 发送方本地 client sequence(列 40005,仅私聊有值,仅 includeIds 时附上)—— * 私聊引用回复要把它当 `replyToClientSeq` 传回去,只用 msgSeq 定位不到原消息。 @@ -945,7 +949,7 @@ export const AI_TOOLS: AiTool[] = [ '读取某个会话的消息,按时间正序(旧→新)返回,方便顺读。默认取最新一页。' + 'kind: c2c=私聊(conv 传对方 uid),group=群聊(conv 传群号)。会话标识可来自 list_conversations / list_groups。' + '每条为精简形:time 时间、sender 发送者昵称、mine 是否本人发送、text 文本;间隔较大时附 gap(距上一条多久)。' + - '想要拿到某条消息的 msgId/msgSeq 以便回查详情或媒体,把 includeIds 设为 true。' + + '想要拿到某条消息的 msgId/msgSeq 以便回查详情或媒体,把 includeIds 设为 true,同时返回原始 Unix 秒 sendTime 和稳定发送者 senderUid。' + '\n【翻页】返回带 hasMore / nextBefore:还想往更早读,就把 nextBefore 原样传回 before 参数取上一页;' + '一次别把 limit 开太大,顺着翻更省 token 也更聚焦。', input: z.object({ @@ -997,7 +1001,9 @@ export const AI_TOOLS: AiTool[] = [ sender: r.senderUin === selfUin ? '我' : nameByUid[r.senderUid] || String(r.senderUin), mine: r.senderUin === selfUin, text: flattenElements(r.elements), - ...(includeIds ? msgIdsOf(r) : {}), + ...(includeIds + ? { ...msgIdsOf(r), sendTime: String(r.sendTime), senderUid: r.senderUid } + : {}), }; // 与上一条(更早那条)的间隔:只在 ≥30 分钟时标注,给「聊天有没有断档」的时间感又不刷屏。 if (i > 0) { @@ -2533,6 +2539,7 @@ export const AI_TOOLS: AiTool[] = [ conv, msgId: String(row.msgId), msgSeq: String(row.msgSeq), + sendTime: String(row.sendTime), time: fmtTime(row.sendTime), sender, mine, @@ -3999,8 +4006,7 @@ export const AI_TOOLS: AiTool[] = [ // 四个粒度:文本 / 媒体 / 文件 / 元素数组逃生舱。都走 services().messageSend, // 业务逻辑(uid 解析、上传、回执归一化)在 @weq/service 里只写一遍。 // - // 真机联调期:这四个工具**临时开放给外部 MCP 面板**(原本标 assistantOnly,被 - // server.ts 过滤掉)。联调结束后应恢复该标记,让外部面板回到严格只读。 + // 发送工具仅限明确授权的内置助手;外部 MCP 的显式只读清单不会注册它们。 // 三个发送工具共用的「带装扮」入参 —— 注意这是**未通过真机验证**的实验开关: // 服务端不采信客户端自报的装扮(实测落库全 0),详见 @weq/protocol 的 SendDress。 @@ -4244,7 +4250,7 @@ export const AI_TOOLS: AiTool[] = [ tool({ name: 'send_rich_message', description: - '给群聊或私聊发送【任意元素组合】的消息(表情/商城表情/引用/@/markdown/XML 卡片/JSON(Ark) 卡片/合并转发卡片/窗口抖动)。' + + '给群聊或私聊发送【任意元素组合】的消息(表情/商城表情/引用/@/markdown/XML 卡片/JSON(Ark) 卡片/合并转发卡片/互动表情)。' + '文本、图片、语音、视频用更省事的 send_text_message / send_media_message,本工具是其余类型的入口。' + '⚠️ 真实发送,不能撤回;需要 QQ 在线。' + '\n【elements 写法】JSON 数组文本,每项必须有 kind:' + @@ -4257,8 +4263,7 @@ export const AI_TOOLS: AiTool[] = [ ' ⚠️ 引用指针 origMsgSeq 群聊填群内 seq、**私聊填被引用消息的 clientSeq**(列 40005,' + 'get_messages 开 includeIds=true 会给);私聊填会话级 msgSeq 会定位不到原消息' + '\n {"kind":"markdown","markdownContent":"**加粗**"} {"kind":"xml","xmlContent":""} {"kind":"ark","arkData":"{...}"}' + - '\n {"kind":"forward","resId":"<已有长消息的 resid>"} {"kind":"poke","subType":1}(窗口抖动,只能私聊且必须独占一条;' + - '可选 "combo":0~3 = 连击次数,缺省 0)' + + '\n {"kind":"forward","resId":"<已有长消息的 resid>"}' + '\n {"kind":"pokeEmoji","pokeId":3,"combo":1}(戳一戳互动表情:pokeId 0~6、combo 0~3,群聊/私聊都能发)' + '\n {"kind":"emojiBounce","faceId":182,"count":10,"name":"笑哭"}(表情弹射:表情「弹进」聊天窗口;' + 'faceId 是小黄脸 id,count 是弹射个数,name 不带斜杠。真机验证可用)' + @@ -4310,8 +4315,7 @@ export const AI_TOOLS: AiTool[] = [ }), // ── 轻互动(真实副作用):戳一戳 / 贴表情回应 ──────────────────────── - // 都是 OIDB 包(不是消息),和上面四个发送工具同属「真机联调期临时开放」, - // 联调结束后同样应恢复 assistantOnly(见本节开头的说明)。 + // 都是 OIDB 包(不是消息),与发送工具一样受宿主的任意权限检查保护。 tool({ name: 'send_forward_message', @@ -4380,9 +4384,8 @@ export const AI_TOOLS: AiTool[] = [ description: '【戳一戳】(OIDB 0xED3_1)—— 群聊里戳某个成员,或私聊戳对方,会在会话里留下一条「戳一戳」灰条。' + '⚠️ 真实发送,不能撤回;需要该账号 QQ 在线。' + - '\n【和窗口抖动的区别】窗口抖动是**私聊消息**里的一个元素(只能私聊、必须独占一条),' + - '用 send_rich_message 发 {"kind":"poke","subType":1};' + - '本工具是聊天窗口里那个「戳一戳」,群聊/私聊都能用,收端显示成灰条而不是抖动。' + + '\n【和互动表情的区别】互动表情用 send_rich_message 发 {"kind":"pokeEmoji","pokeId":1};' + + '本工具发送的是「戳一戳」灰条,不是表情消息。' + '\n【目标怎么写】peerType=group 时 targetId 传群号,targetUin 传被戳成员 QQ 号(不传 = 戳群);' + 'peerType=c2c 时 targetId 传对方 QQ 号或 uid(不传 targetUin = 戳对方)。' + '\n【结果怎么看】成功没有回执内容(服务端回空 ack);失败会抛错误。' + @@ -4962,9 +4965,7 @@ export const AI_TOOLS: AiTool[] = [ // 现场派生(协议实现与推导见 @weq/protocol 的 redbag 与 docs/develop/redbag.md)。 // 所以喂给 decode_blob 只会得到乱码,必须先走这一层。 // - // 真机联调期:pack_red_bag **临时开放给外部 MCP 面板**(原本标 assistantOnly,会被 - // server.ts 过滤掉)。它会在服务端建一个红包订单,属于有副作用的工具; - // 联调结束后应恢复该标记,让外部面板回到严格只读(与上面那批 send_* 同理)。 + // pack_red_bag 会在服务端建订单,只在助手的任意权限模式开放。 tool({ name: 'decode_redbag_packet', @@ -5147,9 +5148,7 @@ export const AI_TOOLS: AiTool[] = [ // 与 pack_red_bag 一样,两个都要「在线且已注入」的 QQ:红包包走 hook 上的 // sendPacket,tenpay.com 的 p_skey 只是请求体里的一个字段。 // - // 真机联调期:grab_red_bag **临时开放给外部 MCP 面板**(它真的会记一笔领取 / - // 扣钱,本应标 assistantOnly,被 server.ts 挡在公开面板外)。联调结束后应恢复 - // 该标记;get_red_bag_detail 是纯查询,可以长期对外开放。 + // grab_red_bag 是真实领取,只在助手任意权限模式开放;get_red_bag_detail 是只读查询。 tool({ name: 'get_red_bag_detail', @@ -5233,17 +5232,16 @@ export const AI_TOOLS: AiTool[] = [ // 硬调,既慢又容易在参数上反复试错。run_js 把这类活交给一段脚本,它是助手唯一的 // 「自己算」入口。 // - // assistantOnly: true —— 沙箱里的 callTool 能调到 execute_sql 这类可写工具, - // 对外只读的 MCP 面板不能开这个口子(server.ts 会过滤掉 assistantOnly)。 + // 脚本仅供内置助手;callTool 仍检查本轮权限快照和当前设置,外部 MCP 不注册。 tool({ name: 'run_js', assistantOnly: true, description: - '在本地**受限 JS 沙箱**里跑一段 JavaScript,用来把「需要自己算」的活一次做完:批量遍历 + 筛选 + 聚合 + 多份结果 join(例如把上百个群逐个核对身份、按角色筛人、把几份统计汇总)。这类活一个个工具零散调用既慢又容易在参数上反复试错,写一段脚本更快更准。' + - '\n【沙箱里有什么】① `callTool(name, args)`:**异步**调用任意其他工具(内置工具和你接入的外部 MCP 工具都可以,参数与直接调用那个工具时完全一致),返回它解析后的结果;② `console.log/info/warn/error`:会被收进返回值的 logs;③ `sleep(ms)`。' + + '按本轮权限模式执行 JavaScript,用来把「需要自己算」的活一次做完:批量遍历 + 筛选 + 聚合 + 多份结果 join(例如把上百个群逐个核对身份、按角色筛人、把几份统计汇总)。这类活一个个工具零散调用既慢又容易在参数上反复试错,写一段脚本更快更准。' + + '\n【脚本里有什么】① `callTool(name, args)`:**异步**调用本轮权限允许的工具(外部 MCP 仅在任意权限开放,参数与直接调用那个工具时一致),返回它解析后的结果;② `console.log/info/warn/error`:会被收进返回值的 logs;③ `sleep(ms)`。' + '\n【怎么用我】代码支持顶层 await,**必须用 `return` 把结果交出来**;若你习惯先写 `async function main() { … }`,那要写成 `return main()`。脚本在还有调用没 await 完时就结束会被**判失败**(否则会变成一个「看似没查到」的假结果)。写循环批量补查时自己控量:**单个脚本最多调 40 次工具**,超了会当场报错,那就把活拆成多轮对话。' + '\n【失败了怎么看】返回值里的 `calls[]` 是每次调用的轨迹,失败那条带 `error` 字段(写明为什么失败,比如参数不合法);`notes[]` 是给你的提醒。照着 error 改参数重试,别直接放弃或改用别的工具硬堆。' + - '\n【限制】沙箱里没有 require / import / fs / fetch / process,拿不到 Node 能力也上不了网,数据只能经 callTool 进来;eval / new Function 被禁用。报错会带上你代码的行号,超时(默认 ' + + '\n【权限】默认沙箱使用 QuickJS/WASM:没有 require / fs / fetch / process,数据只能经 callTool 进来,工具必须通过宿主权限检查。若用户在助手设置中明确启用任意权限,脚本改为 Node 执行,可使用 require / process / fetch,能读写删除外部文件、执行命令及联网,不再具有隔离。超时不等于已发出的宿主操作已回滚,不要自动重试未知终态操作。超时(默认 ' + `${JS_SANDBOX_DEFAULT_TIMEOUT_MS / 1000}s,上限 ${JS_SANDBOX_MAX_TIMEOUT_MS / 1000}s)会被硬中止。` + '\n【结果不能代表什么】result 是脚本的返回值、logs 是脚本自己的输出,两者都不等于工具原文;脚本报错只说明这段代码有问题,不代表数据不存在——先看 error 与 calls 再改代码重试。单个工具一次就能查到的,别用沙箱包一层。', input: z.object({ @@ -5264,7 +5262,14 @@ export const AI_TOOLS: AiTool[] = [ run: async ({ code, timeoutMs }) => { // 动态 import:执行入口在 openai_tools.ts,静态引入会与 AI_TOOLS 绕成一个环。 const { runAssistantTool } = await import('./openai_tools'); - return runJsSandbox(code, runAssistantTool, timeoutMs); + const { toolExecutionOptions } = await import('./account_scope'); + const options = toolExecutionOptions(); + return runJsSandbox( + code, + (name, args, signal) => runAssistantTool(name, args, { ...options, signal }), + timeoutMs, + options, + ); }, }), ]; diff --git a/apps/desktop/src/main/media_protocol.ts b/apps/desktop/src/main/media_protocol.ts index 2a89d8668..870b43f6d 100644 --- a/apps/desktop/src/main/media_protocol.ts +++ b/apps/desktop/src/main/media_protocol.ts @@ -40,6 +40,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { viewerMedia } from './export_viewer'; import { fileResponse as streamFile } from './file_response'; import { decorationImageResponse } from './decoration_image_response'; +import { chatImageResponse } from './chat_image_response'; import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -54,6 +55,7 @@ import { type MediaElement, } from '@weq/service'; import { getAppContext } from './context/app_context'; +import { fullModeAllowed } from './context/full_mode'; import { resolveResource } from './resource'; import { resourceSkinCover } from './redpack_catalog'; import { decodeSilkToWav, isSilkFile } from './voice'; @@ -226,7 +228,7 @@ async function onlineRedbagCover(skinId: string): Promise { const ctx = getAppContext(); const services = ctx.services; if (!services) return null; - if (ctx.bootstrap?.userConfig.getSettings().autoAttachQq === false) return null; + if (!fullModeAllowed(ctx)) return null; const record = services.accountConfig.getRecord(); if (!record?.qqOnline || !record.qqPid) return null; try { @@ -624,16 +626,20 @@ async function routeMediaRequest(request: Request): Promise { ); mediaBinding.getStore()?.(); const path = wantThumb ? (thumb ?? source) : (source ?? thumb); - if (path) return fileResponse(path); + const respond = (path: string) => + wantThumb && picType !== 'emoji' + ? chatImageResponse(path, currentRequest.getStore(), mediaBinding.getStore()) + : fileResponse(path); + if (path) return respond(path); // 本地 miss → 外部安卓 chatpic 备份兜底(按 md5 寻址),再不行才走 CDN。 const chatpicFile = resolveChatpicFallback(q.get('md5') ?? ''); - if (chatpicFile) return fileResponse(chatpicFile); + if (chatpicFile) return respond(chatpicFile); // Missing on disk → CDN: digit token → originalUrl (no rkey); else rkey. const dl = await services.mediaDownload.download(token, { originalUrl: q.get('orig') ?? '', }); mediaBinding.getStore()?.(); - return dl ? fileResponse(dl) : notFound('pic not found'); + return dl ? respond(dl) : notFound('pic not found'); } case 'video': { // ?v=thumb → cover image (rkey is fine for covers); otherwise → diff --git a/apps/desktop/src/main/online_transcribe.ts b/apps/desktop/src/main/online_transcribe.ts index 3ad71af32..74566e8d9 100644 --- a/apps/desktop/src/main/online_transcribe.ts +++ b/apps/desktop/src/main/online_transcribe.ts @@ -26,6 +26,7 @@ import { import { requirePlatform } from './context/app_context'; import { startAccountCapture, + isAccountCaptureCurrent, stopAccountCapture, takeAccountFrames, type AccountCaptureHandle, @@ -65,7 +66,7 @@ export class OnlineVoiceTranscriber { * d2key → 起抓包并排空存量帧。同一个账号重复 arm 会先 disarm 旧的。 */ async arm(uin: string): Promise<{ pid: number; elevated: boolean; hasD2Key: boolean }> { - if (this.armed && this.armed.uin === uin) { + if (this.armed && this.armed.uin === uin && isAccountCaptureCurrent(this.armed.handle)) { return { pid: this.armed.handle.pid, elevated: this.armed.handle.elevated, @@ -76,13 +77,13 @@ export class OnlineVoiceTranscriber { const handle = await startAccountCapture(uin); if (!handle.hasD2Key) { // 没有 d2key 就解不开 push 的密文 —— 直接停掉并报错,别让导出空等。 - await stopAccountCapture(handle.pid); + await stopAccountCapture(handle.pid, handle.sessionId); throw new Error('未能获取该账号的会话密钥(d2key),无法解密在线转写结果'); } // 排空 arm 之前网卡上已有的帧,把游标推到当前末尾。 let cursor = 0; try { - const batch = await takeAccountFrames(handle.pid, { waitMs: 0 }); + const batch = await takeAccountFrames(handle.pid, { waitMs: 0, sessionId: handle.sessionId }); cursor = batch.nextCursor; } catch { cursor = 0; @@ -103,7 +104,7 @@ export class OnlineVoiceTranscriber { this.armed = null; if (!armed) return; try { - await stopAccountCapture(armed.handle.pid); + await stopAccountCapture(armed.handle.pid, armed.handle.sessionId); } catch { /* best-effort */ } @@ -185,6 +186,7 @@ export class OnlineVoiceTranscriber { let batch: Awaited>; try { batch = await takeAccountFrames(armed.handle.pid, { + sessionId: armed.handle.sessionId, cursor, waitMs: Math.min(POLL_WINDOW_MS, Math.max(0, deadline - Date.now())), }); diff --git a/apps/desktop/src/main/qq_launch.ts b/apps/desktop/src/main/qq_launch.ts new file mode 100644 index 000000000..00e9397d0 --- /dev/null +++ b/apps/desktop/src/main/qq_launch.ts @@ -0,0 +1,45 @@ +import { execFile, spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { posix, win32 } from 'node:path'; +import type { Platform } from '@weq/platform'; + +/** 只启动检测到的 QQ 客户端;启动成功不代表账号已登录或在线凭据已就绪。 */ +export async function launchQq(platform: Pick): Promise { + const exe = platform.qqExePath(); + const path = platform.kind === 'win32' ? win32 : posix; + if (!exe || !path.isAbsolute(exe) || !existsSync(exe)) + throw new Error('未检测到 QQ 安装,请先安装 QQ。'); + + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const app = path.dirname(path.dirname(path.dirname(exe))); + if ( + platform.kind === 'darwin' && + path.extname(app) === '.app' && + path.basename(path.dirname(exe)) === 'MacOS' && + path.basename(path.dirname(path.dirname(exe))) === 'Contents' + ) { + // 通过系统打开应用,复用已运行实例并激活登录窗口。 + await new Promise((resolve, reject) => { + execFile('/usr/bin/open', ['-a', app], { timeout: 10_000, env }, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + return; + } + + await new Promise((resolve, reject) => { + const child = spawn(exe, [], { + cwd: path.dirname(exe), + detached: true, + stdio: 'ignore', + env, + }); + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); +} diff --git a/apps/desktop/src/main/remote/http.ts b/apps/desktop/src/main/remote/http.ts index e101ce11f..d5aa41028 100644 --- a/apps/desktop/src/main/remote/http.ts +++ b/apps/desktop/src/main/remote/http.ts @@ -33,6 +33,9 @@ export function createRemoteServer(pairing: RemotePairing, webPort?: () => numbe if (++requests > 120) return send(429, { error: '请求过于频繁。' }); const path = req.url ?? ''; try { + if (req.method === 'GET' && path === '/v1/hello') { + return send(200, { product: 'WeQ Remote', protocol: 1 }); + } if (req.method === 'POST' && path === '/v1/pair') { if (Number(req.headers['content-length']) > 4096) { req.resume(); diff --git a/apps/desktop/src/main/remote/runtime.ts b/apps/desktop/src/main/remote/runtime.ts index 6207f9ea4..7256d02b3 100644 --- a/apps/desktop/src/main/remote/runtime.ts +++ b/apps/desktop/src/main/remote/runtime.ts @@ -42,7 +42,9 @@ export class RemoteRuntime { private store: RemoteStore | null = null; private mode: RemoteConnectionMode = 'lan'; private closing: Promise = Promise.resolve(); - private helperMode: 'lan' | 'both' = 'lan'; + private tailscaleChild: ChildProcessWithoutNullStreams | null = null; + private tailscaleState: z.infer = { state: 'offline' }; + private tailscaleClosing: Promise = Promise.resolve(); configure(config: { helper: string; @@ -72,14 +74,102 @@ export class RemoteRuntime { } async setMode(mode: RemoteConnectionMode): Promise { - const enabled = this.server !== null; this.mode = mode; + this.store?.save({ mode }); + await this.setTailscaleEnabled(mode !== 'lan'); + } + + async setTailscaleEnabled(enabled: boolean): Promise { + this.store?.save({ tailscaleEnabled: enabled }); + if (!enabled) this.stopTailscale(); + else if (this.server) await this.startTailscale(); + } + + private stopTailscale(): void { + const child = this.tailscaleChild; + this.tailscaleChild = null; + this.tailscaleState = { state: 'offline' }; this.pairing.cancelInvitation(); - this.store?.save({ mode, ...(mode !== 'lan' ? { tailscaleEnabled: true } : {}) }); - if (mode !== 'lan' && this.helperMode === 'lan' && enabled) { - this.stop(); - await this.start(); + if (!child) return; + if (child.exitCode === null) { + this.tailscaleClosing = new Promise((resolve) => { + const timer = setTimeout(resolve, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); } + child.stdin.end(); + child.kill(); + } + + private async startTailscale(): Promise { + await this.tailscaleClosing; + if ( + this.tailscaleChild || + !this.server || + !this.config || + !this.state.fingerprint || + !this.store?.read().tailscaleEnabled + ) + return; + const bound = this.server.address(); + if (!bound || typeof bound === 'string') return; + this.tailscaleState = { state: 'starting' }; + let child: ChildProcessWithoutNullStreams; + try { + child = this.launch( + this.config.helper, + [ + '--backend', + `http://127.0.0.1:${bound.port}`, + '--state', + this.store.read().networkIdentity + ? join(this.config.stateDir, this.store.read().networkIdentity!) + : this.config.stateDir, + '--lan-state', + this.config.stateDir, + '--mode', + 'tailscale', + ], + { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }, + ); + } catch { + this.tailscaleState = { state: 'error', error: 'WeQ 专用 Tailscale 服务启动失败。' }; + return; + } + this.tailscaleChild = child; + child.stderr.resume(); + const lines = createInterface({ input: child.stdout }); + lines.on('line', (line) => { + if (this.tailscaleChild !== child || line.length > 8192) return; + try { + const event = networkEvent.parse(JSON.parse(line)); + if (event.authUrl && new URL(event.authUrl).protocol !== 'https:') return; + if ( + event.state === 'online' && + (!/^http:\/\/100\.(?:\d{1,3}\.){2}\d{1,3}:7691$/.test(event.address ?? '') || + remoteDirectAddresses([(event.address ?? '').replace('http:', 'https:')], 'existing') + .length !== 1) + ) + return; + this.tailscaleState = event; + } catch { + /* 只接受专用辅助进程的结构化状态。 */ + } + }); + const failed = () => { + lines.close(); + if (this.tailscaleChild !== child) return; + this.stopTailscale(); + this.tailscaleState = { + state: 'error', + error: 'WeQ 专用 Tailscale 服务已停止,可重新启动。', + }; + }; + child.once('error', failed); + child.once('exit', failed); } async refreshNetwork(): Promise { @@ -89,25 +179,31 @@ export class RemoteRuntime { } async changeNetwork(): Promise { - this.stop(); - const mode = this.mode === 'lan' ? 'tailscale' : this.mode; + this.stopTailscale(); // 更换身份而不删除原节点;旧节点可在 Tailscale 管理台移除。 this.store?.save({ - mode, tailscaleEnabled: true, networkIdentity: `network-${Date.now()}`, }); - this.mode = mode; - await this.start(); + await this.startTailscale(); } status() { + const tailscaleEnabled = this.store?.read().tailscaleEnabled ?? false; + const network = + this.server && tailscaleEnabled && this.state.fingerprint ? this.tailscaleState : this.state; + const addresses = remoteDirectAddresses(this.state.addresses ?? [], 'lan'); + if (this.tailscaleState.state === 'online' && this.tailscaleState.address) { + const ip = new URL(this.tailscaleState.address).hostname; + addresses.push(`https://${ip}:7692`); + } return { available: this.config !== null, helperInstalled: !!this.config && existsSync(this.config.helper), enabled: this.server !== null, mode: this.mode, - network: { ...this.state }, + tailscaleEnabled, + network: { ...network, addresses, fingerprint: this.state.fingerprint }, ...this.pairing.snapshot(), }; } @@ -119,7 +215,6 @@ export class RemoteRuntime { } this.starting = true; await this.closing; - this.helperMode = this.store?.read().tailscaleEnabled || this.mode !== 'lan' ? 'both' : 'lan'; const server = createRemoteServer(this.pairing, this.config.webPort); this.server = server; this.state = { state: 'starting' }; @@ -144,7 +239,7 @@ export class RemoteRuntime { ? join(this.config.stateDir, this.store.read().networkIdentity!) : this.config.stateDir, '--mode', - this.helperMode, + 'lan', '--lan-state', this.config.stateDir, ...(this.config.lanPort === undefined ? [] : ['--lan-port', String(this.config.lanPort)]), @@ -161,13 +256,6 @@ export class RemoteRuntime { const event = networkEvent.parse(JSON.parse(line)); if (event.authUrl && new URL(event.authUrl).protocol !== 'https:') return; if ( - this.helperMode === 'both' && - event.state === 'online' && - !/^http:\/\/100\.(?:\d{1,3}\.){2}\d{1,3}:\d+$/.test(event.address ?? '') - ) - return; - if ( - this.helperMode === 'lan' && event.state === 'online' && (!event.fingerprint || !event.addresses?.every((value) => @@ -176,6 +264,8 @@ export class RemoteRuntime { ) return; this.state = event; + if (event.state === 'online' && this.store?.read().tailscaleEnabled) + void this.startTailscale(); } catch { /* 只接受辅助程序的结构化事件。 */ } @@ -192,6 +282,7 @@ export class RemoteRuntime { this.state = { state: 'error', error: '网络辅助程序已退出。' }; }); this.store?.save({ enabled: true }); + if (this.store?.read().tailscaleEnabled) await this.startTailscale(); } catch (error) { this.stop(); this.state = { state: 'error', error: '远程服务启动失败。' }; @@ -202,6 +293,7 @@ export class RemoteRuntime { } stop(disable = false): void { + this.stopTailscale(); if (disable) this.store?.save({ enabled: false }); const child = this.child; this.child = null; @@ -225,34 +317,30 @@ export class RemoteRuntime { async invite(input?: z.infer) { const bootstrap = remoteInviteInput.parse(input); - const direct = this.mode !== 'tailscale'; - if ( - (this.mode === 'tailscale' && this.state.state !== 'online') || - (direct && !this.state.fingerprint) - ) - throw new Error('请先完成网络登录。'); + const direct = !bootstrap || !('authKey' in bootstrap); + const state = this.status().network; + if ((!direct && this.tailscaleState.state !== 'online') || !state.fingerprint) + throw new Error(direct ? '请先完成网络登录。' : '请先启动并登录 WeQ 专用 Tailscale 服务。'); const selected = bootstrap && 'endpoint' in bootstrap ? bootstrap.endpoint : undefined; - const addresses = remoteDirectAddresses( - this.state.addresses ?? [], - this.mode === 'existing' ? 'existing' : 'lan', - ); + const addresses = state.addresses ?? []; if (selected && !addresses.includes(selected)) throw new Error('所选网络地址已不可用。'); - const endpoint = direct ? (selected ?? addresses[0]) : this.state.address; + const endpoint = direct ? (selected ?? addresses[0]) : this.tailscaleState.address; if (!endpoint) - throw new Error( - this.mode === 'existing' - ? '未检测到 Tailscale 地址,请连接电脑的 Tailscale 后刷新地址。' - : '没有可用的局域网地址,请检查网络连接。', - ); - if (this.mode === 'tailscale' && (!bootstrap || !('authKey' in bootstrap))) - throw new Error('请先填写手机入网 Key。'); + throw new Error('没有可用的连接地址,请检查网络或启动 WeQ 专用 Tailscale 服务。'); const invite = this.pairing.invite(); const url = new URL('weq-remote://pair'); - url.searchParams.set('v', direct ? '3' : bootstrap ? '2' : '1'); - url.searchParams.set('endpoint', endpoint); + url.searchParams.set('v', selected ? '3' : '4'); + if (selected) url.searchParams.set('endpoint', selected); + else { + const lan = remoteDirectAddresses(addresses, 'lan').slice(0, 6); + const own = this.tailscaleState.state === 'online' ? this.tailscaleState.address : undefined; + const tls = remoteDirectAddresses(addresses, 'existing'); + const endpoints = [...new Set([...lan, ...(own ? [own] : []), ...tls])].slice(0, 8); + for (const address of endpoints) url.searchParams.append('endpoint', address); + } url.searchParams.set('invitation', invite.secret); - if (direct) url.searchParams.set('fingerprint', this.state.fingerprint!); - if (bootstrap && 'authKey' in bootstrap && this.mode === 'tailscale') { + url.searchParams.set('fingerprint', state.fingerprint!); + if (bootstrap && 'authKey' in bootstrap) { url.searchParams.set('authKey', bootstrap.authKey); url.searchParams.set('expiresAt', String(invite.expiresAt)); } diff --git a/apps/desktop/src/main/remote/store.ts b/apps/desktop/src/main/remote/store.ts index c91fc9487..7bd2b3eb9 100644 --- a/apps/desktop/src/main/remote/store.ts +++ b/apps/desktop/src/main/remote/store.ts @@ -4,27 +4,32 @@ import { z } from 'zod'; import type { Claim } from './pairing'; import { remoteConnectionMode } from '../../shared/remote_bootstrap'; -const savedSchema = z.object({ - enabled: z.boolean(), - mode: remoteConnectionMode, - tailscaleEnabled: z.boolean().default(false), - networkIdentity: z - .string() - .regex(/^network-[0-9]+$/) - .optional(), - claims: z - .array( - z.object({ - id: z.string().uuid(), - name: z.string().min(1).max(64), - tokenHash: z.string().regex(/^[a-f0-9]{64}$/), - approvedAt: z.number().finite(), - lastSeenAt: z.number().finite().nullable(), - scope: z.enum(['status:read', 'web:access']), - }), - ) - .max(16), -}); +const savedSchema = z + .object({ + enabled: z.boolean(), + mode: remoteConnectionMode, + tailscaleEnabled: z.boolean().optional(), + networkIdentity: z + .string() + .regex(/^network-[0-9]+$/) + .optional(), + claims: z + .array( + z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(64), + tokenHash: z.string().regex(/^[a-f0-9]{64}$/), + approvedAt: z.number().finite(), + lastSeenAt: z.number().finite().nullable(), + scope: z.enum(['status:read', 'web:access']), + }), + ) + .max(16), + }) + .transform((saved) => ({ + ...saved, + tailscaleEnabled: saved.tailscaleEnabled ?? saved.mode !== 'lan', + })); export class RemoteStore { private data: z.infer; diff --git a/apps/desktop/src/main/startup_window.ts b/apps/desktop/src/main/startup_window.ts new file mode 100644 index 000000000..6ed2d9b07 --- /dev/null +++ b/apps/desktop/src/main/startup_window.ts @@ -0,0 +1,64 @@ +export const AUTOSTART_ARG = '--weq-autostart'; +export const MANUAL_RESTART_ARG = '--weq-manual-restart'; + +export function isAutostartLaunch( + argv: readonly string[], + platform: NodeJS.Platform, + wasOpenedAtLogin = false, +): boolean { + return ( + !argv.includes(MANUAL_RESTART_ARG) && + (argv.includes(AUTOSTART_ARG) || (platform === 'darwin' && wasOpenedAtLogin)) + ); +} + +export function shouldShowStartupWindow(input: { + autostart: boolean; + showWindowOnAutostart: boolean; + trayAvailable: boolean; + manualRequested: boolean; +}): boolean { + return ( + !input.autostart || input.showWindowOnAutostart || !input.trayAvailable || input.manualRequested + ); +} + +/** 用户主动重启不继承“系统自启”身份;自动切换窗口后端的重启则保留原参数。 */ +export function manualRestartArgs(argv: readonly string[]): string[] { + return [ + ...argv.slice(1).filter((arg) => arg !== AUTOSTART_ARG && arg !== MANUAL_RESTART_ARG), + MANUAL_RESTART_ARG, + ]; +} + +interface StartupWindow { + isDestroyed(): boolean; + show(): void; + restore(): void; + focus(): void; + setAlwaysOnTop(value: boolean): void; + on(event: 'ready-to-show', listener: () => void): unknown; + once(event: 'closed', listener: () => void): unknown; + webContents: { on(event: 'did-finish-load', listener: () => void): unknown }; +} + +/** 隐藏自启不注册任何兜底显示回调,避免加载完成后又弹出窗口。 */ +export function installStartupWindowReveal(win: StartupWindow, showInitially: boolean): void { + if (!showInitially) return; + const reveal = () => { + if (win.isDestroyed()) return; + win.show(); + win.restore(); + win.focus(); + try { + win.setAlwaysOnTop(true); + win.setAlwaysOnTop(false); + } catch { + // 部分窗口系统不支持置顶,仍保留正常显示与聚焦。 + } + }; + win.on('ready-to-show', reveal); + win.webContents.on('did-finish-load', reveal); + const timer = setTimeout(reveal, 1000); + win.once('closed', () => clearTimeout(timer)); +} diff --git a/apps/desktop/src/main/static_backup.ts b/apps/desktop/src/main/static_backup.ts index 9d0939275..1f908aad3 100644 --- a/apps/desktop/src/main/static_backup.ts +++ b/apps/desktop/src/main/static_backup.ts @@ -48,42 +48,142 @@ export async function resolveBackupDirectory(path: string): Promise void, + signal?: AbortSignal, +) { const location = await resolveBackupLocation(dbDir); if (!location) throw new Error('未找到 QQ 账号数据库目录'); dbDir = location.dbDir; const files = (await readdir(dbDir)).filter((name) => name.endsWith('.db')); - const sizes = await Promise.all( - files.map((name) => - stat(join(dbDir, name)) - .then((s) => s.size) - .catch(() => 0), - ), - ); - return { + const info = { dirPath: location.accountDir, dbDir, databaseCount: files.length, - databaseBytes: sizes.reduce((a, b) => a + b, 0), + databaseBytes: 0, mediaDir: location.mediaDir, }; + let unreadable = 0; + let measured = 0; + onProgress?.({ ...info, databaseBytes: null, sizePending: true }); + for (const name of files) { + signal?.throwIfAborted(); + try { + info.databaseBytes += (await stat(join(dbDir, name))).size; + measured++; + } catch { + unreadable++; + } + onProgress?.({ + ...info, + databaseBytes: measured ? info.databaseBytes : null, + sizePending: true, + }); + } + const result = { + ...info, + databaseBytes: unreadable === files.length && files.length > 0 ? null : info.databaseBytes, + sizePending: false, + ...(unreadable ? { sizeError: `${unreadable} 个数据库大小未能读取` } : {}), + }; + onProgress?.(result); + return result; } -export async function discoverBackups(path: string, recursive = false) { +export async function discoverBackups( + path: string, + recursive = false, + options: { onCandidate?: (info: BackupScanInfo) => void; signal?: AbortSignal } = {}, +) { const seen = new Set(); - const result = await scanDirectories( + const result = await scanDirectories( path, async (directory) => { - const dbDir = await resolveBackupDirectory(directory); - if (!dbDir || seen.has(dbDir)) return []; - seen.add(dbDir); - return [await backupInfo(dbDir)]; + options.signal?.throwIfAborted(); + const location = await resolveBackupLocation(directory); + if (!location || seen.has(location.dbDir)) return []; + seen.add(location.dbDir); + options.onCandidate?.({ + dirPath: location.accountDir, + dbDir: location.dbDir, + mediaDir: location.mediaDir, + databaseCount: null, + databaseBytes: null, + sizePending: true, + }); + try { + return [await backupInfo(location.dbDir, options.onCandidate, options.signal)]; + } catch (error) { + if (options.signal?.aborted) throw error; + const failed = { + dirPath: location.accountDir, + dbDir: location.dbDir, + mediaDir: location.mediaDir, + databaseCount: null, + databaseBytes: null, + sizePending: false, + sizeError: error instanceof Error ? error.message : String(error), + }; + options.onCandidate?.(failed); + return [failed]; + } }, recursive, + options.signal, ); return { candidates: result.items, truncated: result.truncated, unreadable: result.unreadable }; } +export type BackupScanCandidate = BackupScanInfo & { + probe: Awaited> | null; +}; +export type BackupScanEvent = + | { kind: 'candidate'; candidate: BackupScanCandidate } + | { kind: 'done'; truncated: boolean; unreadable: number }; + +/** 目录、文件体积和逐项身份验证分批回传,不等待整批原生探测。 */ +export async function scanBackupCandidates( + platform: Platform, + configs: AccountConfig[], + path: string, + recursive: boolean, + options: Parameters[3], + emit?: (event: BackupScanEvent) => void, + signal?: AbortSignal, +) { + const result = await discoverBackups(path, recursive, { + signal, + onCandidate: (info) => emit?.({ kind: 'candidate', candidate: { ...info, probe: null } }), + }); + const candidates = []; + for (const info of result.candidates) { + signal?.throwIfAborted(); + let probe: Awaited>; + try { + probe = await probeBackup(platform, configs, info.dirPath, options); + } catch (error) { + probe = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + signal?.throwIfAborted(); + const candidate = { ...info, probe }; + candidates.push(candidate); + emit?.({ kind: 'candidate', candidate }); + } + emit?.({ kind: 'done', truncated: result.truncated, unreadable: result.unreadable }); + return { ...result, candidates }; +} + export function matchingBackupConfigs(dbDir: string, configs: AccountConfig[]) { const parent = dirname(dbDir); const accountName = basename(parent) === 'nt_qq' ? basename(dirname(parent)) : basename(parent); diff --git a/apps/desktop/src/main/storage_cleanup.ts b/apps/desktop/src/main/storage_cleanup.ts index ff441d4a8..292a2ce22 100644 --- a/apps/desktop/src/main/storage_cleanup.ts +++ b/apps/desktop/src/main/storage_cleanup.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import type { StorageUsageItem } from '@weq/service'; import { readStorageCleanupResult } from '@weq/service/storage-cleanup'; import { resolveChildWorkerPath } from './worker_path'; +import { skipNextQuitConfirmation } from './app_quit_confirmation'; const reportPath = () => join(app.getPath('userData'), 'storage-maintenance', 'last-result.json'); const lockPath = () => join(app.getPath('userData'), 'storage-maintenance', 'running.json'); @@ -118,7 +119,7 @@ export async function clearStorageAfterExit(targets: StorageUsageItem[]): Promis if (!helper) throw new Error('清除助手未启动'); let committed = false; const commit = (event: Electron.Event) => { - if (committed) return; + if (committed || event.defaultPrevented) return; event.preventDefault(); helper.send({ type: 'commit' }, (error) => { if (error) { @@ -138,5 +139,8 @@ export async function clearStorageAfterExit(targets: StorageUsageItem[]): Promis pending = false; app.removeListener('will-quit', commit); }); - setTimeout(() => app.quit(), 200); + setTimeout(() => { + skipNextQuitConfirmation(); + app.quit(); + }, 200); } diff --git a/apps/desktop/src/main/tool_window.ts b/apps/desktop/src/main/tool_window.ts index 61ec70fd6..c4fdebef4 100644 --- a/apps/desktop/src/main/tool_window.ts +++ b/apps/desktop/src/main/tool_window.ts @@ -24,9 +24,9 @@ export function applicationToolPopupOptions( sandbox: true, }, }; - if (url === 'about:blank' && frameName === 'weq-db-repair') + if (url === 'about:blank' && (frameName === 'weq-db-repair' || frameName === 'weq-capture')) return { - title: 'WeQ · 数据库修复', + title: frameName === 'weq-capture' ? 'WeQ · QQNT 抓包' : 'WeQ · 数据库修复', width: 1060, height: 780, minWidth: 640, diff --git a/apps/desktop/src/main/transcribe/engine.ts b/apps/desktop/src/main/transcribe/engine.ts index 595bc429a..26c1a81b2 100644 --- a/apps/desktop/src/main/transcribe/engine.ts +++ b/apps/desktop/src/main/transcribe/engine.ts @@ -12,27 +12,13 @@ */ import { fork } from 'node:child_process'; -import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { dirname } from 'node:path'; +import { resolveChildWorkerPath } from '../worker_path'; import type { VoiceTagDisplay } from './tags'; const moduleDir = dirname(fileURLToPath(import.meta.url)); -/** - * Locate the bundled `transcribeWorker.mjs`. electron-vite emits it at - * `out/main/transcribeWorker.mjs`, but this module may be chunked into - * `out/main/chunks/`, so `__dirname` isn't guaranteed to be `out/main`. Try the - * sibling path first, then one level up (the chunks case). - */ -function resolveWorkerPath(): string { - const candidates = [ - join(moduleDir, 'transcribeWorker.mjs'), - join(moduleDir, '..', 'transcribeWorker.mjs'), - ]; - return candidates.find((p) => existsSync(p)) ?? candidates[0]!; -} - export interface TranscribeResult { success: boolean; /** Transcript, with every SenseVoice control tag stripped. */ @@ -83,10 +69,9 @@ export function transcribeWav( let worker: ReturnType; try { - // Bundled output sits alongside the main entry in out/main/. - const workerPath = resolveWorkerPath(); + const workerPath = resolveChildWorkerPath('transcribeWorker.mjs', moduleDir); worker = fork(workerPath, [], { - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], serialization: 'advanced', }); } catch (e) { @@ -94,6 +79,16 @@ export function transcribeWav( return; } + let stderr = ''; + worker.stderr?.setEncoding('utf8'); + worker.stderr?.on('data', (chunk: string) => { + stderr = (stderr + chunk).slice(-4096); + }); + const stopWorker = (): void => { + if (worker.connected) worker.disconnect(); + worker.kill(); + }; + worker.on('message', (msg: TranscribeWorkerMessage) => { if (msg?.type === 'final') { done({ @@ -102,36 +97,49 @@ export function transcribeWav( emotion: msg.emotion ?? null, events: msg.events ?? [], }); - worker.disconnect(); - worker.kill(); + stopWorker(); } else if (msg?.type === 'error') { - done({ success: false, error: msg.error }); - worker.disconnect(); - worker.kill(); + done({ success: false, error: msg.error ?? '语音识别失败' }); + stopWorker(); } }); - worker.on('error', (err) => done({ success: false, error: String(err) })); - worker.on('exit', (code, signal) => { + worker.on('error', (err) => { + done({ success: false, error: String(err) }); + stopWorker(); + }); + // close 晚于 exit,确保原生运行库的 stderr 已排空,保留有界诊断。 + worker.on('close', (code, signal) => { if (signal === 'SIGSEGV') { done({ success: false, error: '语音识别引擎崩溃(底层运行库段错误)' }); return; } - if (code !== 0 && code !== null) { - done({ success: false, error: `识别进程异常退出 (code ${code})` }); + if (signal || (code !== 0 && code !== null)) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + done({ + success: false, + error: `识别进程异常退出 (${reason})${stderr.trim() ? `:${stderr.trim()}` : ''}`, + }); } else { // Normal exit after a 'final'/'error' already resolved — no-op. done({ success: false, error: '识别进程未返回结果' }); } }); - worker.send({ - engine: options.engine ?? 'sense-voice', - modelPath: paths.model, - tokensPath: paths.tokens, - wavData: wav, - sampleRate: 16000, - languages: options.languages ?? [], - }); + worker.send( + { + engine: options.engine ?? 'sense-voice', + modelPath: paths.model, + tokensPath: paths.tokens, + wavData: wav, + sampleRate: 16000, + languages: options.languages ?? [], + }, + (error) => { + if (!error) return; + done({ success: false, error: `语音识别请求发送失败:${String(error)}` }); + stopWorker(); + }, + ); }); } diff --git a/apps/desktop/src/main/transcribe/worker.ts b/apps/desktop/src/main/transcribe/worker.ts index 3a51f6c7a..175801588 100644 --- a/apps/desktop/src/main/transcribe/worker.ts +++ b/apps/desktop/src/main/transcribe/worker.ts @@ -15,9 +15,16 @@ import { existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; import { parseSenseVoiceRichText, type VoiceTagDisplay } from './tags'; -const requireFn = createRequire(__filename); +// Node 子进程不读取 asar:使用解包后的 JS 包,与同目录的原生绑定一起加载。 +const resourcesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const requireFn = createRequire( + existsSync(join(resourcesDir, 'app.asar')) + ? join(resourcesDir, 'app.asar.unpacked/node_modules/sherpa-onnx-node/package.json') + : import.meta.url, +); interface WorkerParams { /** sense-voice etc. */ @@ -91,7 +98,13 @@ interface FinalMessage { } function emit(msg: FinalMessage | { type: 'error'; error: string }): void { - if (typeof process.send === 'function') process.send(msg); + const code = msg.type === 'error' ? 1 : 0; + if (typeof process.send !== 'function' || !process.connected) { + process.exit(code); + return; + } + // 等 IPC 写入完成后退出,避免错误或结果仍在发送队列时被截断。 + process.send(msg, (error) => process.exit(error ? 1 : code)); } function normalizeBuffer(data: WorkerParams['wavData']): Buffer { @@ -117,14 +130,12 @@ function run(params: WorkerParams): void { sherpa = requireFn('sherpa-onnx-node'); } catch (e) { emit({ type: 'error', error: `语音识别引擎加载失败: ${String(e)}` }); - process.exit(1); return; } const wav = normalizeBuffer(params.wavData); if (wav.length <= 44) { emit({ type: 'final', text: '', emotion: null, events: [] }); - process.exit(0); return; } @@ -155,10 +166,8 @@ function run(params: WorkerParams): void { // string it would have to re-parse. const rich = parseSenseVoiceRichText(result?.text ?? ''); emit({ type: 'final', text: rich.text, emotion: rich.emotion, events: rich.events }); - process.exit(0); } catch (e) { emit({ type: 'error', error: String(e) }); - process.exit(1); } } diff --git a/apps/desktop/src/main/update/mac.ts b/apps/desktop/src/main/update/mac.ts new file mode 100644 index 000000000..04b900bfc --- /dev/null +++ b/apps/desktop/src/main/update/mac.ts @@ -0,0 +1,170 @@ +import { app, dialog } from 'electron'; +import { execFile, fork, type ChildProcess } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import semver from 'semver'; +import { resolveChildWorkerPath } from '../worker_path'; +import { REPO } from './mirrors'; +import { + isAdhocSignature, + MAC_STAGE_PREFIX, + macAppBundle, + macUpdateResultSchema, + officialMacArchive, + validateMacUpdatePlan, + verifyMacArchive, + verifyMacBundle, + writeMacUpdateResult, + type MacUpdatePlan, +} from './mac_install'; + +const run = promisify(execFile); +const reportPath = () => join(app.getPath('userData'), 'updates', 'mac-install.json'); + +export async function usesMacAdhocUpdater(): Promise { + if (process.platform !== 'darwin') return false; + const { stderr } = await run('/usr/bin/codesign', [ + '-d', + '--verbose=2', + macAppBundle(process.execPath), + ]); + return isAdhocSignature(stderr); +} + +export async function prepareMacUpdate(archive: string, version: string): Promise { + if (!semver.valid(version) || !semver.gt(version, app.getVersion())) + throw new Error('更新版本无效或不高于当前版本'); + const response = await fetch( + `https://api.github.com/repos/${REPO.owner}/${REPO.repo}/releases/tags/v${encodeURIComponent(version)}`, + { + signal: AbortSignal.timeout(20_000), + redirect: 'error', + headers: { Accept: 'application/vnd.github+json' }, + }, + ); + if (!response.ok) + throw new Error(`无法核验官方 Release(HTTP ${response.status}),未安装;请稍后重试。`); + const asset = officialMacArchive(await response.json(), version, basename(archive)); + await verifyMacArchive(archive, asset); + const target = await realpath(macAppBundle(process.execPath)); + await verifyMacBundle(target, app.getVersion()); + await finishMacUpdate(); + let staging: string; + try { + staging = await mkdtemp(join(dirname(target), MAC_STAGE_PREFIX)); + } catch { + throw new Error( + '当前应用位置不可写,请将 WeQ 移到有写入权限的应用程序文件夹后重试,或手动安装。', + ); + } + try { + // 只有通过官方哈希验证的包才会解压;ditto 保留框架软链接和签名封装。 + await run('/usr/bin/ditto', ['-x', '-k', archive, staging], { timeout: 120_000 }); + await verifyMacBundle(join(staging, 'weQ.app'), version, asset.arch); + await mkdir(dirname(reportPath()), { recursive: true }); + const plan: MacUpdatePlan = { + target, + staging, + version, + previousVersion: app.getVersion(), + arch: asset.arch, + report: reportPath(), + }; + await writeMacUpdateResult(plan, 'prepared'); + return plan; + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } +} + +/** 助手确认就绪及 will-quit 提交后才退出;下载或启动助手不会提前关闭账号。 */ +export async function installMacUpdate(plan: MacUpdatePlan): Promise { + const worker = resolveChildWorkerPath( + 'macUpdateWorker.mjs', + dirname(fileURLToPath(import.meta.url)), + ); + const helper: ChildProcess = fork(worker, [], { + detached: true, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + execPath: process.execPath, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + }); + await new Promise((resolve, reject) => { + let committed = false; + const cleanup = () => { + clearTimeout(timer); + app.removeListener('will-quit', commit); + helper.removeListener('error', fail); + helper.removeListener('exit', exited); + helper.removeListener('message', message); + }; + const fail = (error: Error) => { + cleanup(); + if (!committed) helper.kill(); + reject(error); + }; + const exited = () => fail(new Error('更新助手提前退出,未替换应用。')); + const commit = (event: Electron.Event) => { + if (committed || event.defaultPrevented) return; + event.preventDefault(); + helper.send({ type: 'commit' }, (error) => { + if (error) fail(error); + }); + }; + const message = (value: unknown) => { + const type = (value as { type?: string } | null)?.type; + if (type === 'ready') { + app.on('will-quit', commit); + app.quit(); + } else if (type === 'committed') { + committed = true; + cleanup(); + helper.disconnect(); + helper.unref(); + resolve(); + app.quit(); + } else if (type === 'error') { + fail(new Error((value as { message: string }).message)); + } + }; + const timer = setTimeout( + () => fail(new Error('更新助手准备或应用退出超时,未替换应用。')), + 120_000, + ); + helper.once('error', fail); + helper.once('exit', exited); + helper.on('message', message); + helper.send({ type: 'prepare', parentPid: process.pid, plan }, (error) => { + if (error) fail(error); + }); + }); +} + +/** 新版本启动后才清理本次旧应用副本;失败回执明确保留恢复位置。 */ +export async function finishMacUpdate(): Promise { + if (process.platform !== 'darwin' || !app.isPackaged) return; + try { + const record = macUpdateResultSchema.parse(JSON.parse(await readFile(reportPath(), 'utf8'))); + const plan = validateMacUpdatePlan(record.plan); + if (plan.target !== (await realpath(macAppBundle(process.execPath)))) return; + if (record.phase === 'installing') return; + if ( + record.phase === 'prepared' || + (record.phase === 'installed' && plan.version === app.getVersion()) + ) { + await rm(plan.staging, { recursive: true, force: true }); + } else { + dialog.showErrorBox( + '上次更新未完成', + `${record.message ?? '未确认新版本启动。'}\n恢复文件保留在:${plan.staging}`, + ); + } + await rm(reportPath(), { force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + console.warn('[update] macOS 更新回执读取失败', error); + } +} diff --git a/apps/desktop/src/main/update/mac_install.ts b/apps/desktop/src/main/update/mac_install.ts new file mode 100644 index 000000000..615a516be --- /dev/null +++ b/apps/desktop/src/main/update/mac_install.ts @@ -0,0 +1,166 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { lstat, rename, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { z } from 'zod'; + +const run = promisify(execFile); +export const MAC_BUNDLE_ID = 'app.weq.desktop'; +export const MAC_STAGE_PREFIX = '.weq-update-'; + +export const macUpdatePlanSchema = z.object({ + target: z.string(), + staging: z.string(), + version: z.string(), + previousVersion: z.string(), + arch: z.enum(['arm64', 'x64']), + report: z.string(), +}); +export type MacUpdatePlan = z.infer; +export const macUpdateResultSchema = z.object({ + phase: z.enum(['prepared', 'installing', 'installed', 'error']), + plan: macUpdatePlanSchema, + message: z.string().optional(), +}); + +export async function writeMacUpdateResult( + plan: MacUpdatePlan, + phase: z.infer['phase'], + message?: string, +): Promise { + const temporary = `${plan.report}.tmp`; + await writeFile(temporary, JSON.stringify({ phase, plan, message }), { mode: 0o600 }); + await rename(temporary, plan.report); +} + +/** ad-hoc 默认 DR 是单个构建的 cdhash,不能作为跨版本的发布者身份。 */ +export function isAdhocSignature(details: string): boolean { + return /^Signature=adhoc\r?$/m.test(details); +} + +export function macAppBundle(executable: string): string { + const bundle = resolve(dirname(executable), '../..'); + if ( + !isAbsolute(executable) || + basename(bundle) !== 'weQ.app' || + dirname(executable) !== join(bundle, 'Contents', 'MacOS') + ) + throw new Error('无法识别当前 macOS 应用位置,请将 WeQ 安装到应用程序文件夹后重试。'); + return bundle; +} + +const releaseSchema = z.object({ + tag_name: z.string(), + draft: z.literal(false), + prerelease: z.literal(false), + assets: z.array( + z.object({ + name: z.string(), + state: z.literal('uploaded'), + digest: z.string().nullable().optional(), + size: z.number().int().positive(), + }), + ), +}); + +/** 信任锚只来自官方 HTTPS API,不接受加速站同时提供包与自报哈希。 */ +export function officialMacArchive( + data: unknown, + version: string, + fileName: string, +): { + arch: 'arm64' | 'x64'; + sha256: string; + size: number; +} { + const release = releaseSchema.parse(data); + if (release.tag_name !== `v${version}` && release.tag_name !== version) + throw new Error('官方 Release 版本与更新包不一致'); + const arch = + fileName === `weQ-${version}-mac-arm64.zip` + ? 'arm64' + : fileName === `weQ-${version}-mac-x64.zip` + ? 'x64' + : null; + const asset = release.assets.find((item) => item.name === fileName); + if (!arch || !asset || !/^sha256:[a-f0-9]{64}$/.test(asset.digest ?? '')) + throw new Error('官方 Release 缺少此架构更新包的 SHA-256,未安装;请稍后重试或手动下载。'); + return { arch, sha256: asset.digest?.slice(7) ?? '', size: asset.size }; +} + +export async function verifyMacArchive(path: string, expected: { sha256: string; size: number }) { + const stat = await lstat(path); + if (!stat.isFile() || stat.size !== expected.size) + throw new Error('更新包大小与官方 Release 不一致'); + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + if (hash.digest('hex') !== expected.sha256) + throw new Error('更新包与官方 SHA-256 不匹配,未安装'); +} + +export async function verifyMacBundle(bundle: string, version?: string, arch?: 'arm64' | 'x64') { + if (!(await lstat(bundle)).isDirectory()) throw new Error('更新应用不是普通目录'); + await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', bundle], { timeout: 120_000 }); + const plist = join(bundle, 'Contents', 'Info.plist'); + const field = async (key: string) => + (await run('/usr/bin/plutil', ['-extract', key, 'raw', '-o', '-', plist])).stdout.trim(); + if ((await field('CFBundleIdentifier')) !== MAC_BUNDLE_ID) throw new Error('更新应用标识不匹配'); + if (version && (await field('CFBundleShortVersionString')) !== version) + throw new Error('更新应用版本不匹配'); + const executable = await field('CFBundleExecutable'); + if (!executable || basename(executable) !== executable) throw new Error('更新应用可执行文件无效'); + if (arch) + await run('/usr/bin/lipo', [ + '-verify_arch', + arch === 'x64' ? 'x86_64' : arch, + join(bundle, 'Contents', 'MacOS', executable), + ]); +} + +export function validateMacUpdatePlan(input: unknown): MacUpdatePlan { + const plan = macUpdatePlanSchema.parse(input); + if ( + ![plan.target, plan.staging, plan.report].every(isAbsolute) || + basename(plan.target) !== 'weQ.app' || + !basename(plan.staging).startsWith(MAC_STAGE_PREFIX) || + dirname(plan.staging) !== dirname(plan.target) || + resolve(plan.staging) !== plan.staging || + resolve(plan.target) !== plan.target + ) + throw new Error('更新安装路径无效'); + return plan; +} + +/** 同卷 rename,不覆盖原目录;任一步失败都尽力恢复旧应用,保留失败现场。 */ +export async function replaceMacApp(input: MacUpdatePlan): Promise { + const plan = validateMacUpdatePlan(input); + const next = join(plan.staging, 'weQ.app'); + const previous = join(plan.staging, 'previous.app'); + await verifyMacBundle(plan.target, plan.previousVersion); + await verifyMacBundle(next, plan.version, plan.arch); + let moved = false; + let replaced = false; + try { + await rename(plan.target, previous); + moved = true; + await rename(next, plan.target); + replaced = true; + await run('/usr/bin/open', ['-n', plan.target], { timeout: 30_000 }); + } catch (error) { + if (moved) { + try { + if (replaced) await rename(plan.target, join(plan.staging, 'failed.app')); + await rename(previous, plan.target); + } catch (rollback) { + throw new AggregateError( + [error, rollback], + `更新失败且自动恢复未完成,旧应用保存在 ${previous}`, + ); + } + await run('/usr/bin/open', ['-n', plan.target], { timeout: 30_000 }).catch(() => {}); + } + throw error; + } +} diff --git a/apps/desktop/src/main/update/mac_worker.ts b/apps/desktop/src/main/update/mac_worker.ts new file mode 100644 index 000000000..fd94fb869 --- /dev/null +++ b/apps/desktop/src/main/update/mac_worker.ts @@ -0,0 +1,71 @@ +import { + replaceMacApp, + validateMacUpdatePlan, + verifyMacBundle, + writeMacUpdateResult, + type MacUpdatePlan, +} from './mac_install'; +import { join } from 'node:path'; + +let prepared: { plan: MacUpdatePlan; parentPid: number } | undefined; +let committed = false; +const timer = setTimeout(() => process.exit(1), 120_000); + +process.on('message', async (input: unknown) => { + const message = input as { type?: string; parentPid?: number; plan?: unknown }; + try { + if ( + message.type === 'prepare' && + !prepared && + !committed && + message.parentPid === process.ppid + ) { + const plan = validateMacUpdatePlan(message.plan); + await verifyMacBundle(plan.target, plan.previousVersion); + await verifyMacBundle(join(plan.staging, 'weQ.app'), plan.version, plan.arch); + prepared = { plan, parentPid: message.parentPid }; + process.send?.({ type: 'ready' }); + } else if (message.type === 'commit' && prepared && !committed) { + const { plan, parentPid } = prepared; + await writeMacUpdateResult(plan, 'installing'); + committed = true; + clearTimeout(timer); + process.send?.({ type: 'committed' }); + try { + const deadline = Date.now() + 30_000; + for (;;) { + try { + process.kill(parentPid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') break; + throw error; + } + if (Date.now() >= deadline) throw new Error('应用仍在运行,未替换应用。'); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await replaceMacApp(plan); + await writeMacUpdateResult(plan, 'installed'); + } catch (error) { + await writeMacUpdateResult( + plan, + 'error', + error instanceof Error ? error.message : String(error), + ); + process.exitCode = 1; + } + if (process.connected) process.disconnect(); + } + } catch (error) { + if (process.connected) + process.send?.({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + }); + clearTimeout(timer); + process.exitCode = 1; + if (process.connected) process.disconnect(); + } +}); +process.on('disconnect', () => { + if (!committed) process.exit(1); +}); diff --git a/apps/desktop/src/main/update/updater.ts b/apps/desktop/src/main/update/updater.ts index 00c188da0..7d774c6dc 100644 --- a/apps/desktop/src/main/update/updater.ts +++ b/apps/desktop/src/main/update/updater.ts @@ -7,8 +7,8 @@ * the running version. Works in dev too (no Electron packaging needed). * - DOWNLOAD + INSTALL — electron-updater's `autoUpdater` (generic provider), * pointed at the winning mirror, for proven sha512-verified download + - * silent NSIS install + relaunch. On a mirror error we fall back down the - * ranked list before giving up. + * silent install + relaunch. macOS ad-hoc 构建另核官方包并由退出后助手替换, + * 不使用绑定旧 cdhash 的 Squirrel。镜像下载错误仍按排名回退。 * * State + events flow to the renderer through the `update` tRPC router: * - `updateBus.emit('progress', UpdateProgress)` → settings progress bar @@ -20,6 +20,8 @@ import semver from 'semver'; import electronUpdater from 'electron-updater'; import { FILE_MIRRORS, resolveBestMirror } from './mirrors'; import { announceUpdateAvailableSafe } from '../weq_assistant/update_tweet'; +import { finishMacUpdate, installMacUpdate, prepareMacUpdate, usesMacAdhocUpdater } from './mac'; +import type { MacUpdatePlan } from './mac_install'; import { getUpdateState, setUpdateActions, @@ -92,6 +94,18 @@ export async function checkForUpdate(force = false): Promise { let wired = false; let downloaded = false; let installing = false; +let downloading = false; +let macAdhoc = false; +let macDownload: { file: string; version: string } | null = null; +let macPlan: MacUpdatePlan | null = null; + +function installFailed(error: unknown): void { + installing = false; + updateBus.emit('event', { + kind: 'error', + message: error instanceof Error ? error.message : String(error), + } satisfies UpdateEvent); +} /** Attach the persistent autoUpdater listeners once (progress + downloaded). */ function wireAutoUpdater(): void { @@ -116,6 +130,10 @@ function wireAutoUpdater(): void { }); autoUpdater.on('update-downloaded', (info) => { + if (macAdhoc) { + macDownload = { file: info.downloadedFile, version: info.version }; + return; + } downloaded = true; updateBus.emit('event', { kind: 'downloaded', latest: info.version } satisfies UpdateEvent); }); @@ -123,8 +141,7 @@ function wireAutoUpdater(): void { // macOS 的系统准备/安装发生在下载 Promise 结束之后,仍需把错误送回界面。 autoUpdater.on('error', (error) => { if (!installing) return; - installing = false; - updateBus.emit('event', { kind: 'error', message: error.message } satisfies UpdateEvent); + installFailed(error); }); } @@ -161,7 +178,21 @@ function attemptDownload(base: string): Promise { */ export async function startDownload(): Promise { if (isDev()) throw new Error('开发模式不支持自更新,请使用打包后的安装版。'); + if (downloading || installing) return; + downloading = true; + downloaded = false; + try { + await downloadUpdate(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + updateBus.emit('event', { kind: 'error', message } satisfies UpdateEvent); + throw error; + } finally { + downloading = false; + } +} +async function downloadUpdate(): Promise { if (!getUpdateState()?.hasUpdate) { await checkForUpdate(true); } @@ -171,9 +202,13 @@ export async function startDownload(): Promise { throw new Error('当前已是最新版本或没有可用的更新源。'); } + macAdhoc = await usesMacAdhocUpdater(); + macDownload = null; + macPlan = null; wireAutoUpdater(); let lastErr: Error | null = null; + let success = false; for (const base of ranked) { try { // Let the UI name the mirror that's actually serving this attempt — the @@ -186,18 +221,30 @@ export async function startDownload(): Promise { hasUpdate: true, } satisfies UpdateEvent); await attemptDownload(base); - return; // success — 'update-downloaded' already notified the renderer + success = true; + break; } catch (e) { lastErr = e instanceof Error ? e : new Error(String(e)); } } - const message = lastErr?.message ?? '更新下载失败'; - updateBus.emit('event', { kind: 'error', message } satisfies UpdateEvent); - throw new Error(message); + if (!success) throw lastErr ?? new Error('更新下载失败'); + if (macAdhoc) { + // MacUpdater 的应用层事件早于 Squirrel 验证;ad-hoc 必须另核官方包后才就绪。 + const update = macDownload as { file: string; version: string } | null; + if (!update?.file) throw new Error('未取得 macOS 更新包路径'); + macPlan = await prepareMacUpdate(update.file, update.version); + downloaded = true; + updateBus.emit('event', { kind: 'downloaded', latest: update.version } satisfies UpdateEvent); + } } -/** Quit, run the downloaded NSIS installer silently, and relaunch. */ +/** 安装已获用户确认,退出时不再重复询问普通助手依赖。 */ +export function isInstallingUpdate(): boolean { + return installing; +} + +/** 下载验证完成后退出、安装并重新打开应用。 */ export function quitAndInstall(): void { if (isDev()) throw new Error('开发模式不支持自更新。'); if (!downloaded) throw new Error('更新尚未下载完成,请先下载更新。'); @@ -206,19 +253,24 @@ export function quitAndInstall(): void { updateBus.emit('event', { kind: 'installing' } satisfies UpdateEvent); // (isSilent, isForceRunAfter): silent install in place, then relaunch. try { - autoUpdater.quitAndInstall(true, true); + if (macPlan) void installMacUpdate(macPlan).catch(installFailed); + else autoUpdater.quitAndInstall(true, true); } catch (error) { - installing = false; - updateBus.emit('event', { - kind: 'error', - message: error instanceof Error ? error.message : String(error), - } satisfies UpdateEvent); + installFailed(error); throw error; } } /** Wire this Electron implementation into the shell-agnostic update router. */ export function installUpdateActions(): void { + if (process.platform === 'darwin' && app.isPackaged) { + // 等新版本真正完成首个页面加载,不能在原生初始化前就删除回退副本。 + app.once('browser-window-created', (_event, window) => { + window.webContents.once('did-finish-load', () => { + void finishMacUpdate(); + }); + }); + } setUpdateActions({ supported: true, check: checkForUpdate, diff --git a/apps/desktop/src/main/vip_badge.ts b/apps/desktop/src/main/vip_badge.ts index 26af36807..9a91224b7 100644 --- a/apps/desktop/src/main/vip_badge.ts +++ b/apps/desktop/src/main/vip_badge.ts @@ -59,6 +59,7 @@ export class VipBadgeService { */ onCredentialsReady: (handler: () => void) => () => void, private readonly onChanged: VipChangedHandler, + private readonly allowOnline: () => boolean = () => true, ) { this.logger = getLogger().child({ scope: 'vip-badge', accountUin: uin }); this.unsubscribe = onCredentialsReady(() => { @@ -83,7 +84,7 @@ export class VipBadgeService { /** 刷新一次;最小间隔挡住短时间内重复触发,同一时刻只跑一个。 */ private refresh(reason: string): Promise { - if (this.stopped) return Promise.resolve(); + if (this.stopped || !this.allowOnline()) return Promise.resolve(); if (this.inflight) return this.inflight; if (Date.now() - this.lastAttemptAt < MIN_REFRESH_GAP_MS) return Promise.resolve(); @@ -116,9 +117,13 @@ export class VipBadgeService { uin: this.uin, pid, seedPskey: seed ? { [VIP_PSKEY_DOMAIN]: seed } : undefined, + beforeRequest: () => { + if (this.stopped || !this.allowOnline()) throw new Error('当前来源未启用完整功能'); + }, onSeedInvalidated: (domain) => this.accountConfig.clearLoginPskey(domain), }); } catch (error) { + if (this.stopped || !this.allowOnline()) return; // 票据被服务端判失效时,落盘那份一并作废:否则下次开账号还会先白失败一轮。 // `fetchAccountVipInfo` 在离线分支不会触发失效回调(换不出新票),这里补一刀; // 在线分支它自己会调,重复删是同值幂等操作。 @@ -140,7 +145,7 @@ export class VipBadgeService { return; } - if (this.stopped) return; + if (this.stopped || !this.allowOnline()) return; this.accountConfig.setVipInfo(vip); this.logger.info('refreshed account vip info', { event: 'vip-fetched', diff --git a/apps/desktop/src/main/web/host.ts b/apps/desktop/src/main/web/host.ts index 4b28e62f4..22331f8ea 100644 --- a/apps/desktop/src/main/web/host.ts +++ b/apps/desktop/src/main/web/host.ts @@ -17,6 +17,8 @@ export interface WebHostOptions { /** Directory that receives exports/saves. Created if missing. */ exportDir: string; version: string; + /** 内置 Web 只借用电脑的离屏渲染,不借用文件框或系统管理权限。 */ + renderer?: Pick; } /** Pending downloads, keyed by the id handed to the client. */ @@ -48,7 +50,7 @@ export function lookupDownload(id: string) { return entry; } -export function createWebHost({ exportDir, version }: WebHostOptions): HostBridge { +export function createWebHost({ exportDir, version, renderer }: WebHostOptions): HostBridge { mkdirSync(exportDir, { recursive: true }); return { @@ -57,10 +59,7 @@ export function createWebHost({ exportDir, version }: WebHostOptions): HostBridg return { url: `/_download/${registerDownload(path, name, kind)}` }; }, canReveal: false, - // 浏览器版:整个服务由部署方用 systemd / 计划任务托管,自启动一律不参与 - // —— 既不注册 GUI(web 入口是 `node server.mjs`,需要参数 + 工作目录), - // 也让守护进程跳过自身注册,避免和部署方的 unit 抢同一份生命周期 - // (见 apps/web/README)。 + // Web 的进程生命周期由部署方管理,不注册桌面登录项。 canAutostart: false, async pickDirectory() { @@ -108,16 +107,19 @@ export function createWebHost({ exportDir, version }: WebHostOptions): HostBridg return { url: `/_download/${id}` }; }, - async renderHtmlToPdf() { + async renderHtmlToPdf(html) { + if (renderer) return renderer.renderHtmlToPdf(html); // 服务端没有 Electron 渲染引擎;年度报告 PDF 导出只在桌面版提供。 throw new Error('web 环境不支持 PDF 渲染,请使用桌面版'); }, - async renderHtmlToSlidesPng() { + async renderHtmlToSlidesPng(html, opts) { + if (renderer) return renderer.renderHtmlToSlidesPng(html, opts); throw new Error('web 环境不支持图片渲染,请使用桌面版'); }, - async renderHtmlToLongPng() { + async renderHtmlToLongPng(html) { + if (renderer) return renderer.renderHtmlToLongPng(html); throw new Error('web 环境不支持图片渲染,请使用桌面版'); }, diff --git a/apps/desktop/src/main/web/runtime.ts b/apps/desktop/src/main/web/runtime.ts index db45e8ff8..98f7b189c 100644 --- a/apps/desktop/src/main/web/runtime.ts +++ b/apps/desktop/src/main/web/runtime.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import type { Server, IncomingMessage } from 'node:http'; import type { AnyRouter } from '@trpc/server'; +import type { HostBridge } from '@weq/service'; import { AuthGate } from './auth'; import { startServer } from './http'; import { createWebHost } from './host'; @@ -15,6 +16,7 @@ interface WebConfig { exportDir: string; version: string; router: AnyRouter; + renderer?: Pick; pairing: RemotePairing; getAutoStart: () => boolean; setAutoStart: (enabled: boolean) => void; @@ -80,7 +82,11 @@ export class DesktopWebRuntime { : auth.watchSession(req, revoke), }; try { - const host = createWebHost({ exportDir: config.exportDir, version: config.version }); + const host = createWebHost({ + exportDir: config.exportDir, + version: config.version, + renderer: config.renderer, + }); const { server, port } = await startServer({ port: 7690, host: '127.0.0.1', diff --git a/apps/desktop/src/main/weq_assistant/ipc.ts b/apps/desktop/src/main/weq_assistant/ipc.ts index ac2fa902e..f3988c7ac 100644 --- a/apps/desktop/src/main/weq_assistant/ipc.ts +++ b/apps/desktop/src/main/weq_assistant/ipc.ts @@ -2,7 +2,7 @@ * Electron-only IPC surface for the WeQ 助手. The renderer's `applyTheme` calls * the handler whenever accent / 深浅 changes (and once on hydrate). * - * Theme is **baked into the published docroot files** (the daemon serves static + * Theme is **baked into the published docroot files** (the app serves static * HTML/PNG only), so a theme change re-publishes them — trailing-debounced: * hydrate + a quick accent sweep emits several calls in a row, and each render * pass (satori → PNG) is too expensive to run per keystroke. The publish step diff --git a/apps/desktop/src/main/weq_assistant/publish.ts b/apps/desktop/src/main/weq_assistant/publish.ts index 771072091..d629fb85d 100644 --- a/apps/desktop/src/main/weq_assistant/publish.ts +++ b/apps/desktop/src/main/weq_assistant/publish.ts @@ -1,8 +1,7 @@ /** * WeQ 助手推文「发布」—— 把每条推物化成 docroot 下的静态文件。 * - * 守护进程(weq-daemon)只是个「端口 + 目录」的静态文件服务器,路由 1:1 对应 - * 推文的 `coverPath` / `pagePath`(见 daemon httpd.rs): + * 当前宿主的资源服务(server.ts)按推文 `coverPath` / `pagePath` 提供静态文件: * * /p/daily → /p/daily.html * /cover/daily → /cover/daily.png (无扩展名请求由 httpd 补 .png/.html) @@ -11,7 +10,7 @@ * /avatar.png → /avatar.png * * 渲染函数全部来自原 server.ts 时代的纯函数(satori/resvg + HTML 字符串拼接, - * 零 Electron 依赖),主题在渲染时烘焙进产物 —— 守护进程不读任何配置。 + * 零 Electron 依赖),主题在渲染时烘焙进产物。 * 主题变更 / 统计快照刷新后重新调用 {@link publishWeqAssistantDocroot} 即可。 */ @@ -34,7 +33,7 @@ function todayLabel(): string { } // ── 页面渲染(自 server.ts 原样迁移) ───────────────────────────────────── -// 原实现内联在 HTTP handler 里;守护进程模式下没有 handler,这里改成纯函数。 +// 页面生成与资源请求分开,HTTP 请求不重复渲染。 /** * Inline lucide icons (paths copied verbatim from `lucide-react@0.469`). The push diff --git a/apps/desktop/src/main/weq_assistant/server.ts b/apps/desktop/src/main/weq_assistant/server.ts new file mode 100644 index 000000000..1c1e330d8 --- /dev/null +++ b/apps/desktop/src/main/weq_assistant/server.ts @@ -0,0 +1,210 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { readFile, realpath, stat } from 'node:fs/promises'; +import { extname, isAbsolute, join, relative, sep } from 'node:path'; + +const MAX_FILE_SIZE = 16 * 1024 * 1024; +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.gif': 'image/gif', + '.json': 'application/json', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', +}; + +/** 只在宿主进程内运行;停用可重启,退出则永久拒绝迟到的启动请求。 */ +export class AssistantResourceServer { + private server: Server | null = null; + private root: string | null = null; + private requestedPort: number | null = null; + private port: number | null = null; + private quitting = false; + private revision = 0; + private pending: Promise = Promise.resolve(); + + status() { + return { running: this.server?.listening ?? false, port: this.port }; + } + + private serialize(action: () => Promise): Promise { + const task = this.pending.then(action); + this.pending = task.catch(() => {}); + return task; + } + + start(port: number, docroot: string): Promise { + return this.serialize(async () => { + if (this.quitting) throw new Error('WeQ 正在退出,未启动助手资源服务'); + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('助手端口无效'); + const root = await realpath(docroot); + if (!(await stat(root)).isDirectory()) throw new Error('助手资源目录无效'); + if (this.server?.listening && this.root === root && this.requestedPort === port) + return this.port!; + await this.close(); + for (let offset = 0; offset < (port === 0 ? 1 : 20) && port + offset <= 65535; offset += 1) { + if (this.quitting) throw new Error('WeQ 正在退出,未启动助手资源服务'); + const server = createServer((req, res) => { + void serve(root, req, res); + }); + server.requestTimeout = 10_000; + server.headersTimeout = 10_000; + server.keepAliveTimeout = 1000; + server.maxConnections = 32; + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port + offset, '127.0.0.1', () => { + server.removeListener('error', reject); + resolve(); + }); + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EADDRINUSE') continue; + throw error; + } + this.server = server; + this.root = root; + this.requestedPort = port; + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('无法读取助手监听端口'); + this.port = address.port; + if (this.quitting) { + await this.close(); + throw new Error('WeQ 正在退出,已停止助手资源服务'); + } + return address.port; + } + throw new Error('助手端口及相邻端口均被占用'); + }); + } + + async publishAndStart( + port: number, + docroot: string, + publish: () => Promise, + ): Promise { + const revision = ++this.revision; + if (this.quitting) return null; + await publish(); + if (this.quitting || revision !== this.revision) return null; + const bound = await this.start(port, docroot); + return revision === this.revision ? bound : null; + } + + stop(): Promise { + this.revision += 1; + return this.serialize(() => this.close()); + } + + shutdown(): Promise { + this.quitting = true; + return this.stop(); + } + + private async close(): Promise { + const server = this.server; + this.server = null; + this.root = null; + this.port = null; + this.requestedPort = null; + if (!server) return; + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } +} + +function respond( + req: IncomingMessage, + res: ServerResponse, + status: number, + body: string | Buffer, + type = 'text/plain; charset=utf-8', +) { + if (res.destroyed) return; + res.writeHead(status, { + 'content-type': type, + 'content-length': Buffer.byteLength(body), + 'cache-control': 'no-cache', + 'x-content-type-options': 'nosniff', + }); + res.end(req.method === 'HEAD' ? undefined : body); +} + +async function serve(root: string, req: IncomingMessage, res: ServerResponse): Promise { + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.setHeader('allow', 'GET, HEAD'); + respond(req, res, 405, 'method not allowed'); + return; + } + let path: string; + try { + path = decodeURIComponent((req.url ?? '/').split('?')[0]!); + } catch { + respond(req, res, 400, 'bad path'); + return; + } + const segments = path.split('/'); + if ( + segments.some( + (segment) => + segment === '..' || + segment.includes('\\') || + segment.includes('\0') || + segment.includes(':'), + ) + ) { + respond(req, res, 404, 'not found'); + return; + } + if (path === '/healthz') { + respond(req, res, 200, 'ok'); + return; + } + const base = join(root, ...segments.filter((segment) => segment && segment !== '.')); + const candidates = extname(base) + ? [base] + : [ + base, + ...['html', 'png', 'jpg', 'jpeg', 'webp', 'svg'].map((ext) => `${base}.${ext}`), + join(base, 'index.html'), + ]; + for (const candidate of candidates) { + try { + const file = await realpath(candidate); + const rel = relative(root, file); + if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) continue; + const info = await stat(file); + if (!info.isFile()) continue; + if (info.size > MAX_FILE_SIZE) { + respond(req, res, 413, 'file too large'); + return; + } + const bytes = await readFile(file); + if (bytes.length > MAX_FILE_SIZE) { + respond(req, res, 413, 'file too large'); + return; + } + respond( + req, + res, + 200, + bytes, + MIME[extname(file).toLowerCase()] ?? 'application/octet-stream', + ); + return; + } catch { + /* 未发布或正在替换的资源按缺失处理,不暴露本机路径。 */ + } + } + respond(req, res, 404, 'not found'); +} + +export const assistantResourceServer = new AssistantResourceServer(); diff --git a/apps/desktop/src/main/weq_assistant/update_tweet.ts b/apps/desktop/src/main/weq_assistant/update_tweet.ts index 2d6fa2da9..0f0b3f61d 100644 --- a/apps/desktop/src/main/weq_assistant/update_tweet.ts +++ b/apps/desktop/src/main/weq_assistant/update_tweet.ts @@ -2,8 +2,7 @@ * 「更新可用」推文 —— 应用内更新检查(打包版)发现新版本时,把一条 * 「WeQ x.y.z 更新可用」推文发进 WeQ 助手。 * - * 守护进程 Release 监控(daemon/release_monitor.ts)只负责发现新版本时弹系统 - * 通知;本模块是唯一把新版本写成 WeQ 助手推文的入口,由应用自身的更新检查 + * 本模块是唯一把新版本写成 WeQ 助手推文的入口,由应用自身的更新检查 * (`update/updater.ts` 的 checkForUpdate)触发,路由固定 `/p/update` + * `/cover/update`。 * diff --git a/apps/desktop/src/main/window_layout.ts b/apps/desktop/src/main/window_layout.ts new file mode 100644 index 000000000..43bcceb2c --- /dev/null +++ b/apps/desktop/src/main/window_layout.ts @@ -0,0 +1,38 @@ +/** 登录采用竖版窗口;主界面仍使用原有聊天尺寸与最小边界。 */ +export const WINDOW_LAYOUTS = { + home: { width: 440, height: 680, minWidth: 360, minHeight: 480 }, + chat: { width: 1180, height: 760, minWidth: 940, minHeight: 520 }, +} as const; +export type WindowLayout = keyof typeof WINDOW_LAYOUTS; +type Bounds = { x: number; y: number; width: number; height: number }; +type LayoutWindow = { + isMaximized(): boolean; + isFullScreen(): boolean; + getBounds(): Bounds; + getContentSize(): number[]; + setMinimumSize(width: number, height: number): void; + setContentSize(width: number, height: number): void; + setPosition(x: number, y: number): void; +}; + +export function applyWindowLayout(win: LayoutWindow, layout: WindowLayout, area: Bounds): void { + if (win.isMaximized() || win.isFullScreen()) return; + const size = WINDOW_LAYOUTS[layout] ?? WINDOW_LAYOUTS.home; + const before = win.getBounds(); + const [contentWidth = before.width, contentHeight = before.height] = win.getContentSize(); + const frameWidth = Math.max(0, before.width - contentWidth); + const frameHeight = Math.max(0, before.height - contentHeight); + // 先解除聊天窗口的最小宽度,否则 Electron 会把竖版尺寸夹回 940px。 + win.setMinimumSize(Math.min(size.minWidth, area.width), Math.min(size.minHeight, area.height)); + win.setContentSize( + Math.max(1, Math.min(size.width, area.width - frameWidth)), + Math.max(1, Math.min(size.height, area.height - frameHeight)), + ); + const after = win.getBounds(); + const x = Math.round(before.x + (before.width - after.width) / 2); + const y = Math.round(before.y + (before.height - after.height) / 2); + win.setPosition( + Math.max(area.x, Math.min(x, area.x + area.width - after.width)), + Math.max(area.y, Math.min(y, area.y + area.height - after.height)), + ); +} diff --git a/apps/desktop/src/preload/development_diagnostics.ts b/apps/desktop/src/preload/development_diagnostics.ts new file mode 100644 index 000000000..d893f85bd --- /dev/null +++ b/apps/desktop/src/preload/development_diagnostics.ts @@ -0,0 +1,275 @@ +import { + DEVELOPMENT_ANIMATION_KINDS, + DEVELOPMENT_COMPONENTS, + DEVELOPMENT_DIAGNOSTIC_VERSION, + DEVELOPMENT_MAIN_VIEWS, + DEVELOPMENT_SURFACES, + diagnosticNumber, +} from '../shared/renderer_diagnostics'; + +export const DEVELOPMENT_SCAN_LIMITS = { + roots: 64, + components: 512, + media: 512, + animations: 1024, +} as const; + +// Node 类型检查不依赖 DOM lib;同时避免跨隔离世界的 instanceof 判定。 +export interface DiagnosticElement { + isConnected: boolean; + matches(selector: string): boolean; + closest(selector: string): DiagnosticElement | null; + getAttribute(name: string): string | null; + getClientRects(): { length: number }; + getElementsByTagName(name: string): { length: number }; + naturalWidth?: number; + naturalHeight?: number; + complete?: boolean; + paused?: boolean; + ended?: boolean; + width?: number; + height?: number; +} +export interface DiagnosticAnimation { + playState: string; + animationName?: string; + effect?: { target?: unknown; pseudoElement?: string | null } | null; +} +export interface DevelopmentDiagnosticDocument { + querySelectorAll(selector: string): ArrayLike; +} + +const SURFACES = { + bootstrap: '.weq-home-shell', + chat: '.message-scroll', + export: '.weq-export-tool', + exportViewer: '.weq-export-viewer', + collections: '.weq-collection-dialog', + qzone: '.weq-qzn', + mail: '.weq-mail-workspace', + annual: '.weq-report-root', + settings: '.weq-settings-dialog', + dressup: '.weq-dress-dialog', + cache: '.weq-cache', + agentlab: '.weq-agentlab-workspace', + other: '', +} satisfies Record<(typeof DEVELOPMENT_SURFACES)[number], string>; +const COMPONENTS = { + messageRows: '.message-line', + forwardRows: '.weq-forward-row', + exportCards: '.weq-exp-card', + exportLogs: '.weq-exp-log-line', + collectionItems: '.weq-col-card', + qzonePosts: '.weq-qzn-post', + shimmers: '.weq-shimmer-block, .weq-exp-skel, .weq-exp-card-skeleton', + widgets: '.weq-avatar-pendant-img', + retainedPanels: '[data-retained-panel]', + workspacePages: '.weq-workspace-page', +} satisfies Record<(typeof DEVELOPMENT_COMPONENTS)[number], string>; +const SURFACE_SELECTOR = Object.values(SURFACES).filter(Boolean).join(', '); + +function animationKind(name: string | undefined): (typeof DEVELOPMENT_ANIMATION_KINDS)[number] { + if (/^weq-bubbleframe-\d+$/.test(name ?? '')) return 'bubbleFrames'; + if (/^weq-widgetframe-\d+$/.test(name ?? '')) return 'widgetFrames'; + if (/^weq-fontfx-\d+-\d+$/.test(name ?? '')) return 'fontFrames'; + if (/^(weq-|rp-|reaction-likers-).*(shimmer|skeleton)/.test(name ?? '')) return 'shimmer'; + if (/^(weq-|rp-|qq-media-|ark-|voice-).*spin$/.test(name ?? '') || name === 'spin') + return 'spinner'; + return 'other'; +} + +function animationTarget(value: unknown): DiagnosticElement | null { + return value !== null && + typeof value === 'object' && + 'closest' in value && + typeof value.closest === 'function' && + 'matches' in value && + typeof value.matches === 'function' + ? (value as DiagnosticElement) + : null; +} + +function surfaceFor(target: DiagnosticElement): (typeof DEVELOPMENT_SURFACES)[number] { + const root = target.closest(SURFACE_SELECTOR); + return root + ? (DEVELOPMENT_SURFACES.find((key) => SURFACES[key] && root.matches(SURFACES[key])) ?? 'other') + : 'other'; +} + +function pixels(width: number | undefined, height: number | undefined): number { + const value = (diagnosticNumber(width) ?? 0) * (diagnosticNumber(height) ?? 0); + return diagnosticNumber(value) ?? 0; +} + +/** 每次请求独立聚合,不保留节点/动画;缺失或异常分类单独记为未知。 */ +export function collectDevelopmentDetails( + document: DevelopmentDiagnosticDocument | undefined, + animations: readonly DiagnosticAnimation[] | undefined, +) { + let mainView: string | null = null; + const surfaces: Record = {}; + const components: Record = {}; + let media: object | null = null; + if (document) { + try { + const view = document + .querySelectorAll('[data-weq-diagnostic-view]')[0] + ?.getAttribute('data-weq-diagnostic-view'); + mainView = DEVELOPMENT_MAIN_VIEWS.find((candidate) => candidate === view) ?? null; + } catch { + /* 导航期间保留未知页面。 */ + } + for (const key of DEVELOPMENT_SURFACES) { + if (!SURFACES[key]) continue; + try { + const roots = document.querySelectorAll(SURFACES[key]); + const sampled = Math.min(roots.length, DEVELOPMENT_SCAN_LIMITS.roots); + let rendered = 0; + let descendantElements = 0; + for (let i = 0; i < sampled; i++) { + const root = roots[i]!; + if (!root.closest('[hidden]') && root.getClientRects().length > 0) rendered++; + descendantElements += root.getElementsByTagName('*').length; + } + surfaces[key] = { + mounted: roots.length, + sampled, + rendered, + descendantElements, + omitted: roots.length - sampled, + }; + } catch { + surfaces[key] = null; + } + } + for (const key of DEVELOPMENT_COMPONENTS) { + try { + const nodes = document.querySelectorAll(COMPONENTS[key]); + const sampled = Math.min(nodes.length, DEVELOPMENT_SCAN_LIMITS.components); + let hiddenByAttribute = 0; + for (let i = 0; i < sampled; i++) if (nodes[i]!.closest('[hidden]')) hiddenByAttribute++; + components[key] = { + count: nodes.length, + sampled, + hiddenByAttribute, + omitted: nodes.length - sampled, + }; + } catch { + components[key] = null; + } + } + try { + const images = document.querySelectorAll('img'); + const videos = document.querySelectorAll('video'); + const canvases = document.querySelectorAll('canvas'); + const sampledImages = Math.min(images.length, DEVELOPMENT_SCAN_LIMITS.media); + const sampledVideos = Math.min(videos.length, DEVELOPMENT_SCAN_LIMITS.media); + const sampledCanvases = Math.min(canvases.length, DEVELOPMENT_SCAN_LIMITS.media); + let completeImages = 0, + pendingImages = 0, + emptyCompleteImages = 0, + naturalPixels = 0, + maxNaturalPixels = 0; + let playingVideos = 0, + canvasPixels = 0; + for (let i = 0; i < sampledImages; i++) { + const image = images[i]!; + const area = pixels(image.naturalWidth, image.naturalHeight); + if (!image.complete) pendingImages++; + else if (area > 0) completeImages++; + else emptyCompleteImages++; + naturalPixels += area; + maxNaturalPixels = Math.max(maxNaturalPixels, area); + } + for (let i = 0; i < sampledVideos; i++) + if (!videos[i]!.paused && !videos[i]!.ended) playingVideos++; + for (let i = 0; i < sampledCanvases; i++) + canvasPixels += pixels(canvases[i]!.width, canvases[i]!.height); + media = { + images: images.length, + sampledImages, + omittedImages: images.length - sampledImages, + completeImages, + pendingImages, + emptyCompleteImages, + naturalPixels, + maxNaturalPixels, + videos: videos.length, + sampledVideos, + playingVideos, + omittedVideos: videos.length - sampledVideos, + canvases: canvases.length, + sampledCanvases, + canvasPixels, + omittedCanvases: canvases.length - sampledCanvases, + frames: document.querySelectorAll('iframe, webview').length, + }; + } catch { + /* 媒体节点不可读时不阻断其它分类。 */ + } + } + let animationDetails: object | null = null; + if (animations) { + const sampled = Math.min(animations.length, DEVELOPMENT_SCAN_LIMITS.animations); + const states: Record = { + running: 0, + paused: 0, + finished: 0, + idle: 0, + other: 0, + }; + const byKind = Object.fromEntries( + DEVELOPMENT_ANIMATION_KINDS.map((key) => [key, { count: 0, running: 0 }]), + ); + const bySurface = Object.fromEntries( + DEVELOPMENT_SURFACES.map((key) => [key, { count: 0, running: 0 }]), + ); + let hiddenTargets = 0, + detachedTargets = 0, + unknownTargets = 0, + pseudoTargets = 0; + for (let i = 0; i < sampled; i++) { + const animation = animations[i]!; + const running = animation.playState === 'running'; + const state = Object.hasOwn(states, animation.playState) ? animation.playState : 'other'; + states[state] = (states[state] ?? 0) + 1; + const kind = byKind[animationKind(animation.animationName)]!; + kind.count++; + if (running) kind.running++; + let surface: (typeof DEVELOPMENT_SURFACES)[number] = 'other'; + try { + const target = animationTarget(animation.effect?.target); + if (!target) unknownTargets++; + else { + if (!target.isConnected) detachedTargets++; + if (target.closest('[hidden]')) hiddenTargets++; + surface = surfaceFor(target); + } + if (animation.effect?.pseudoElement) pseudoTargets++; + } catch { + unknownTargets++; + } + bySurface[surface]!.count++; + if (running) bySurface[surface]!.running++; + } + animationDetails = { + sampled, + omitted: animations.length - sampled, + hiddenTargets, + detachedTargets, + unknownTargets, + pseudoTargets, + states, + byKind, + bySurface, + }; + } + return { + schemaVersion: DEVELOPMENT_DIAGNOSTIC_VERSION, + mainView, + surfaces, + components, + media, + animationDetails, + }; +} diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index c9708a058..9082910f8 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -1,11 +1,13 @@ import type { ElectronAPI } from '@electron-toolkit/preload'; import type { AnalyticsExportPayload, AnalyticsExportResult } from '../shared/analytics_export'; import type { EditUndoBridge } from '../shared/edit_menu'; +import type { LongTermAuthorizationBridge } from '../shared/long_term_authorization'; declare global { interface Window { electron: ElectronAPI; weq: { + authorization: LongTermAuthorizationBridge; editUndo: EditUndoBridge; openLogDir(): Promise; /** 用户选 / 拖 / 粘贴文件的绝对路径(Electron webUtils);拿不到返回 ''。 */ @@ -56,6 +58,8 @@ declare global { capture: { window(): Promise<{ ok: boolean; error?: string }>; }; + /** 通话视频上行(二进制 IPC,不经 superjson)。 */ + callVideo: import('../shared/call_video_ipc').CallVideoBridge; analyticsShot: { /** 主进程在隐藏窗口里渲染同一张卡片、抓图、弹保存框(用户窗口全程不动)。 */ render(payload: AnalyticsExportPayload): Promise; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 182a78011..f07c8e47b 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -16,6 +16,8 @@ import { createRequire } from 'node:module'; import type { AnalyticsExportPayload, AnalyticsExportResult } from '../shared/analytics_export'; import { installRendererDiagnosticSampling } from './renderer_diagnostics'; import type { EditMenuState } from '../shared/edit_menu'; +import { CALL_VIDEO_FRAME_CHANNEL, type CallVideoFrame } from '../shared/call_video_ipc'; +import type { LongTermAuthorizationBridge } from '../shared/long_term_authorization'; // Same CJS workaround as src/main/index.ts — electron-trpc 0.7's ESM // build statically imports `ipcMain` from 'electron', which is not @@ -42,6 +44,13 @@ ipcRenderer.on('edit:undo', () => { ipcRenderer.send('edit:undo-ready'); const weqBridge = { + authorization: { + status: () => ipcRenderer.invoke('authorization:status'), + install: (expectedDevelopment?: boolean) => + ipcRenderer.invoke('authorization:install', expectedDevelopment), + remove: (expectedDevelopment?: boolean) => + ipcRenderer.invoke('authorization:remove', expectedDevelopment), + } satisfies LongTermAuthorizationBridge, editUndo: { onRefresh(handler: () => void): () => void { const listener = () => handler(); @@ -59,6 +68,19 @@ const weqBridge = { }, }, openLogDir: (): Promise => ipcRenderer.invoke('logs:open-dir') as Promise, + /** + * 往主进程日志文件里写一条结构化记录(fire-and-forget)。 + * + * 渲染层的 `console.*` 不会落盘,通话视频解码这类纯渲染层的链路出了问题就 + * 完全看不见。调用方显式调用才记,别拿来替代 `console`。 + */ + debugLog: (level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: object) => { + try { + ipcRenderer.send('logs:renderer', { level, message, context }); + } catch { + // 日志永远不能影响业务流程。 + } + }, /** * 用户选 / 拖 / 粘贴进来的文件的**本机绝对路径**(Electron 32+ 的 `webUtils`)。 * @@ -72,9 +94,6 @@ const weqBridge = { return ''; } }, - /** Reveal the daemon docroot (推文静态文件目录) in the system file manager. */ - revealPath: (path: string): Promise => - ipcRenderer.invoke('daemon:reveal-path', path) as Promise, channel: { /** Open (or focus) the built-in QQ 频道 browser for the current account. * Pass WeQ's theme preference so the window follows 深/浅 mode. */ @@ -173,6 +192,18 @@ const weqBridge = { claimPayload: () => ipcRenderer.invoke('analytics-shot:claim') as Promise, }, + callVideo: { + /** + * 推一帧 I420(通话视频上行)。 + * + * 走原生 IPC 而不是 tRPC:`Uint8Array` 在 `ipcRenderer.send` 里直接结构化 + * 克隆(0.17 ms),经 tRPC + superjson 会被展开成十进制数组(一帧 1.2 MB + * 文本、30 ms)。视频帧本来就该 fire-and-forget,不等回执。 + */ + pushFrame(frame: CallVideoFrame): void { + ipcRenderer.send(CALL_VIDEO_FRAME_CHANNEL, frame); + }, + }, }; if (process.contextIsolated) { diff --git a/apps/desktop/src/preload/renderer_diagnostics.ts b/apps/desktop/src/preload/renderer_diagnostics.ts index 607e5fc3d..487c8d8eb 100644 --- a/apps/desktop/src/preload/renderer_diagnostics.ts +++ b/apps/desktop/src/preload/renderer_diagnostics.ts @@ -5,6 +5,11 @@ import { RESOURCE_DIAGNOSTIC_ID, RESOURCE_DIAGNOSTIC_REQUEST, } from '../shared/renderer_diagnostics'; +import { + collectDevelopmentDetails, + type DevelopmentDiagnosticDocument, + type DiagnosticAnimation, +} from './development_diagnostics'; /** 只响应主进程采样,不暴露网页调用入口或读取正文。 */ export function installRendererDiagnosticSampling(): void { @@ -24,7 +29,8 @@ export function installRendererDiagnosticSampling(): void { dispatchEvent(event: Event): boolean; getElementById(id: string): { getAttribute(name: string): string | null } | null; getElementsByTagName(name: '*'): { length: number }; - getAnimations?(): { playState: string }[]; + getAnimations?(): DiagnosticAnimation[]; + querySelectorAll?: DevelopmentDiagnosticDocument['querySelectorAll']; visibilityState?: string; }; } @@ -43,8 +49,9 @@ export function installRendererDiagnosticSampling(): void { } let developmentSample: object | undefined; if (development === true) { + const started = performance.now(); let caches: Electron.ResourceUsage | null = null; - let animations: { playState: string }[] | undefined; + let animations: DiagnosticAnimation[] | undefined; try { caches = webFrame.getResourceUsage(); } catch { @@ -56,6 +63,12 @@ export function installRendererDiagnosticSampling(): void { // 导航或销毁期间动画统计可能不可用。 } developmentSample = { + ...collectDevelopmentDetails( + preloadDocument?.querySelectorAll + ? { querySelectorAll: (selector) => preloadDocument.querySelectorAll!(selector) } + : undefined, + animations, + ), caches, animations: animations?.length, runningAnimations: animations?.filter((animation) => animation.playState === 'running') @@ -64,6 +77,7 @@ export function installRendererDiagnosticSampling(): void { typeof preloadDocument?.visibilityState === 'string' ? preloadDocument.visibilityState === 'visible' : null, + sampleDurationMs: performance.now() - started, }; } ipcRenderer.send(DIAGNOSTIC_SAMPLE_RESPONSE, sequence, { diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index db97e621f..dd55d26d8 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -14,11 +14,13 @@ import { RemoteRequests } from './components/RemoteRequests'; import { HoverTooltip } from './components/HoverTooltip'; import { ToastHost, useToast } from './components/Toast'; import { DbRepairWindowHost } from './components/DbRepairWindow'; +import { CaptureWindowHost } from './components/CaptureWindow'; import { WelcomeDialog } from './components/WelcomeDialog'; import { JoinGroupDialog } from './components/JoinGroupDialog'; import { CloseConfirmDialog } from './components/CloseConfirmDialog'; import { RestrictedModeHost } from './components/RestrictedMode'; -import { DesktopOnly } from './lib/target'; +import { DesktopOnly, IS_WEB } from './lib/target'; +import { WebConnectionStatus } from './components/WebConnectionStatus'; import { ImageLightbox } from './components/ImageLightbox'; import { VideoLightbox } from './components/VideoLightbox'; import { RedBagDetailHost } from './components/RedBagDetailDialog'; @@ -31,7 +33,7 @@ import { LinkPreviewContext, MsgDecorationEnabledContext, } from './components/QqMessageContent'; -import { SelfPendantContext } from './hooks/useSelfPendant'; +import { SelfBubbleContext, SelfPendantContext } from './hooks/useSelfPendant'; import { ActiveWidgetProvider } from './hooks/useActiveWidget'; import { CdnContext, setPreferCdnFlag } from './lib/cdn'; import { WarmupSplash } from './components/WarmupSplash'; @@ -43,6 +45,8 @@ import { usePrivacyStore } from './state/privacy'; import { useAccountSwitch } from './state/accountSwitch'; import { ShowMessageSeqContext, ShowMessageTimeContext } from './components/MessageProvenance'; import { ContentSelectionProvider } from './state/contentSelection'; +import { ChatImageCacheProvider } from './components/ChatImageCacheProvider'; +import { DEFAULT_CHAT_IMAGE_CACHE_MIB } from '@weq/service/chat-image-cache'; /** * 把两个「气泡渲染」开关广播给所有消息气泡:纯文本 Markdown、链接预览卡片。 @@ -52,11 +56,21 @@ import { ContentSelectionProvider } from './state/contentSelection'; * 复用 QqMessageContent)。`?? true` 与 DEFAULT_APP_SETTINGS 一致,settings 还在加载时 * 不会先闪成关再跳开。 */ -function TextMarkdownProvider({ children }: { children: ReactNode }): ReactElement { +function TextMarkdownProvider({ + children, + cacheScope, +}: { + children: ReactNode; + cacheScope: string; +}): ReactElement { const settings = trpc.bootstrap.getSettings.useQuery(undefined, { refetchOnWindowFocus: false, staleTime: 60_000, }); + const bubble = settings.data?.showMessageBubble ?? settings.data?.showMsgDecoration ?? true; + const font = settings.data?.showMessageFont ?? settings.data?.showMsgDecoration ?? true; + const widget = settings.data?.showAvatarPendant ?? true; + const messageDecoration = useMemo(() => ({ bubble, font, widget }), [bubble, font, widget]); useEffect(() => { if (settings.data?.toastDurations) useToast.getState().setDurations(settings.data.toastDurations); @@ -64,10 +78,15 @@ function TextMarkdownProvider({ children }: { children: ReactNode }): ReactEleme return ( - + - {children} + + {children} + @@ -77,7 +96,7 @@ function TextMarkdownProvider({ children }: { children: ReactNode }): ReactEleme } /** - * 把「自己头像的挂件」广播给所有气泡。理由同上——每条消息一个 Avatar,不能各自订阅。 + * 广播自己的静态头像挂件与待发送气泡的账号配置。每条消息不能各自订阅。 * * `accountOpen` 为假时不查 getHomeDress:那个 procedure 要求已打开账号,在首页 * (bootstrap 视图)调用会抛错。 @@ -100,7 +119,11 @@ function SelfPendantProvider({ }); const enabled = settings.data?.showAvatarPendant ?? true; const url = enabled ? dressUrl(dress.data?.widgetUrl ?? '') : ''; - return {children}; + return ( + + {children} + + ); } /** @@ -168,7 +191,7 @@ export default function App(): ReactElement { // 全打在已关闭的账号上)。 return ( - + {/* 生效挂件与自己的静态挂件同层级广播,也必须盖住转发窗口。 */} @@ -191,8 +214,10 @@ export default function App(): ReactElement { + {IS_WEB && } + diff --git a/apps/desktop/src/renderer/src/components/AntiRecallDialog.tsx b/apps/desktop/src/renderer/src/components/AntiRecallDialog.tsx index 81e178c39..57098af45 100644 --- a/apps/desktop/src/renderer/src/components/AntiRecallDialog.tsx +++ b/apps/desktop/src/renderer/src/components/AntiRecallDialog.tsx @@ -1,7 +1,7 @@ /** - * 防撤回面板 —— 「更多 → 防撤回」打开。 + * 防撤回:设置来源展示页内摘要,选择与记录按需打开工具。 * - * 三页: + * 非设置来源保留原独立面板的三页: * • 保护:总开关、保护范围(按会话勾选 / 永久全选)、受保护会话选择。 * • 通知:撤回通知总开关 + 按会话通知集。通知由主进程发系统原生通知,点通知 * 会唤起窗口并跳到那条消息。 @@ -13,12 +13,21 @@ * 也不需要 QQ 在线。 */ -import { useEffect, useMemo, useState, type ReactElement } from 'react'; -import { BellRing, ChevronRight, RotateCcw, ShieldCheck, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'; +import { + ArrowLeft, + BellRing, + ChevronRight, + Pencil, + RotateCcw, + Save, + ShieldCheck, + X, +} from 'lucide-react'; import { trpc } from '../trpc/client'; import { useDialog } from './Dialog'; import { useToast } from './Toast'; -import { Card, Row, SettingsScopeBadge, Toggle } from './settings/controls'; +import { Card, Row, QueryError, SettingsScopeBadge, Toggle } from './settings/controls'; import { ConversationPicker } from '../views/export/ConversationPicker'; import { convAvatarUrl, fmtCount, type PickItem } from '../views/export/types'; import { isDataline, deviceAvatarDataUri } from '../lib/deviceAvatar'; @@ -26,6 +35,11 @@ import { classifyChatType, datalineName } from '@weq/codec'; import { Avatar } from '../im-template/template/primitives'; import { OverlayLayer } from './OverlayLayer'; import { cn } from '../im-template/template/classNames'; +import { ToolSurface } from './ToolSurface'; +import { PageShell } from './PageShell'; +import { useTabActive } from './RetainedTabs'; +import { useAppLock } from '../state/lock'; +import '../styles/anti-recall-settings.css'; /** 触发器过滤所用的会话类型(与后端 AntiRecallKind 对齐)。 */ type AntiRecallKind = 'c2c' | 'group' | 'dataline'; @@ -84,6 +98,10 @@ function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); } +function sameIds(left: ReadonlySet, right: ReadonlySet): boolean { + return left.size === right.size && [...left].every((id) => right.has(id)); +} + /** 时间戳(unix 秒)→ 「今天 12:30」/「8/12 12:30」。 */ function formatWhen(ts: number): string { if (!ts) return ''; @@ -102,28 +120,86 @@ export function AntiRecallDialog({ onClose, onOpenRecalls, embedded = false, + recallDetails, + onCloseRecalls, + isCurrentSource, }: { onClose: () => void; /** 打开某个会话的撤回列表(由 MainView 渲染,复用聊天区气泡与筛选)。 */ onOpenRecalls: (kind: 'c2c' | 'group', conv: string) => void; embedded?: boolean; + recallDetails?: ReactNode; + onCloseRecalls?: () => void; + isCurrentSource?: () => boolean; }): ReactElement { const showError = useDialog((s) => s.showError); const pushToast = useToast((s) => s.push); const utils = trpc.useUtils(); const [tab, setTab] = useState('protect'); + const [editor, setEditor] = useState(null); + const active = useTabActive(); + const currentActivity = useRef(active); + currentActivity.current = active; + const locked = useAppLock((state) => state.locked); + const mounted = useRef(true); + const epoch = useRef(0); + const closeRecalls = useRef(onCloseRecalls); + closeRecalls.current = onCloseRecalls; + const directoryEntry = useRef(null); + const directoryRoot = useRef(null); + const directoryScroll = useRef(0); + const detailsRoot = useRef(null); + const showingDetails = editor === 'records' && !!recallDetails; + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + epoch.current++; + closeRecalls.current?.(); + }; + }, []); + useEffect(() => { + if (locked || !active) { + epoch.current++; + setEditor(null); + closeRecalls.current?.(); + } + }, [locked, active]); + useEffect(() => { + if (editor !== 'records') return; + if (showingDetails) + detailsRoot.current + ?.querySelector('select, input, button, [tabindex]') + ?.focus({ preventScroll: true }); + else { + if (directoryRoot.current) directoryRoot.current.scrollTop = directoryScroll.current; + directoryEntry.current?.focus({ preventScroll: true }); + } + }, [editor, showingDetails]); + const mutationGuard = () => { + const started = epoch.current; + return () => + mounted.current && + currentActivity.current && + epoch.current === started && + !useAppLock.getState().locked && + (isCurrentSource?.() ?? true); + }; const status = trpc.account.antiRecall.getStatus.useQuery(undefined, { + enabled: active && !locked, refetchOnWindowFocus: false, staleTime: 0, refetchOnMount: 'always', }); const conversations = trpc.account.listConversationsWithCount.useQuery(undefined, { + enabled: active && !locked && (!embedded || editor !== null), refetchOnWindowFocus: false, }); // PC 快照静态账号的库 QQ 不会往里写 —— 触发器拦不到任何东西。 const config = trpc.account.getAccountConfig.useQuery(undefined, { + enabled: active && !locked, refetchOnWindowFocus: false, }); const isStaticPcOnly = (config.data?.static ?? false) && !(config.data?.mobile ?? false); @@ -174,32 +250,40 @@ export function AntiRecallDialog({ // id → kind 映射:选择器只回传 id 集合,保存时据此还原每个会话的 kind。 const kindById = useMemo(() => { const m = new Map(); + for (const target of status.data?.targets ?? []) m.set(target.id, target.kind); + for (const key of status.data?.notifyTargets ?? []) { + const [kind, ...parts] = key.split(':'); + if ((kind === 'c2c' || kind === 'group' || kind === 'dataline') && parts.length) + m.set(parts.join(':'), kind); + } for (const c of (conversations.data ?? []) as ConvWire[]) { if (c.targetUid) m.set(c.targetUid, kindOf(c.chatType, c.targetUid)); } return m; - }, [conversations.data]); + }, [conversations.data, status.data?.targets, status.data?.notifyTargets]); // ── 保护 ──────────────────────────────────────────────────────────────── const [selected, setSelected] = useState>(new Set()); useEffect(() => { const t = status.data?.targets; - if (t) setSelected(new Set(t.map((x) => x.id))); - }, [status.data?.targets]); + if (t && (!embedded || editor !== 'protect')) { + const next = new Set(t.map((target) => target.id)); + setSelected((current) => (sameIds(current, next) ? current : next)); + } + }, [status.data?.targets, embedded, editor]); const savedIds = useMemo( () => new Set((status.data?.targets ?? []).map((t) => t.id)), [status.data?.targets], ); - const dirty = useMemo(() => { - if (selected.size !== savedIds.size) return true; - for (const id of selected) if (!savedIds.has(id)) return true; - return false; - }, [selected, savedIds]); + const dirty = useMemo(() => !sameIds(selected, savedIds), [selected, savedIds]); async function onToggleEnabled(next: boolean): Promise { + const current = mutationGuard(); + if (!current()) return; try { const res = await setEnabled.mutateAsync({ enabled: next }); + if (!current()) return; applyStatus(res); pushToast({ tone: next ? 'success' : 'info', @@ -213,6 +297,7 @@ export function AntiRecallDialog({ : '触发器已卸下。', }); } catch (e) { + if (!current()) return; void status.refetch(); showError(next ? '开启防撤回失败' : '关闭防撤回失败', errMsg(e)); } @@ -220,23 +305,32 @@ export function AntiRecallDialog({ async function onSetMode(next: AntiRecallMode): Promise { if (next === mode) return; + const current = mutationGuard(); + if (!current()) return; try { - applyStatus(await setMode.mutateAsync({ mode: next })); + const result = await setMode.mutateAsync({ mode: next }); + if (current()) applyStatus(result); } catch (e) { + if (!current()) return; void status.refetch(); showError('切换保护范围失败', errMsg(e)); } } async function onSaveTargets(): Promise { + const current = mutationGuard(); + if (!current()) return; const targets: Target[] = [...selected].map((id) => ({ kind: kindById.get(id) ?? 'c2c', id, })); try { - applyStatus(await setTargets.mutateAsync({ targets })); + const result = await setTargets.mutateAsync({ targets }); + if (!current()) return; + applyStatus(result); pushToast({ tone: 'success', title: '已保存受保护会话' }); } catch (e) { + if (!current()) return; void status.refetch(); showError('保存失败', errMsg(e)); } @@ -255,14 +349,14 @@ export function AntiRecallDialog({ ); const [notifySelected, setNotifySelected] = useState>(new Set()); useEffect(() => { - setNotifySelected(new Set(notifyIds)); - }, [notifyIds]); + if (!embedded || editor !== 'notify') + setNotifySelected((current) => (sameIds(current, notifyIds) ? current : new Set(notifyIds))); + }, [notifyIds, embedded, editor]); - const notifyDirty = useMemo(() => { - if (notifySelected.size !== notifyIds.size) return true; - for (const id of notifySelected) if (!notifyIds.has(id)) return true; - return false; - }, [notifySelected, notifyIds]); + const notifyDirty = useMemo( + () => !sameIds(notifySelected, notifyIds), + [notifySelected, notifyIds], + ); // 只有被保护的会话才可能记录下撤回 —— 按会话勾选模式下,通知列表也只列这些。 const notifyItems = useMemo( @@ -271,20 +365,29 @@ export function AntiRecallDialog({ ); async function onToggleNotify(next: boolean): Promise { + const current = mutationGuard(); + if (!current()) return; try { - applyStatus(await setNotify.mutateAsync({ enabled: next })); + const result = await setNotify.mutateAsync({ enabled: next }); + if (current()) applyStatus(result); } catch (e) { + if (!current()) return; void status.refetch(); showError('通知设置失败', errMsg(e)); } } async function onSaveNotify(): Promise { + const current = mutationGuard(); + if (!current()) return; const targets = [...notifySelected].map((id) => notifyKeyOf(kindById.get(id) ?? 'c2c', id)); try { - applyStatus(await setNotify.mutateAsync({ targets })); + const result = await setNotify.mutateAsync({ targets }); + if (!current()) return; + applyStatus(result); pushToast({ tone: 'success', title: '已保存通知会话' }); } catch (e) { + if (!current()) return; void status.refetch(); showError('保存失败', errMsg(e)); } @@ -295,7 +398,8 @@ export function AntiRecallDialog({ refetchOnWindowFocus: false, staleTime: 0, refetchOnMount: 'always', - enabled: tab === 'records', + enabled: + active && !locked && (embedded ? editor === 'records' && !recallDetails : tab === 'records'), }); const nameById = useMemo(() => { @@ -310,10 +414,335 @@ export function AntiRecallDialog({ }, [summaries.data]); function openRecords(kind: AntiRecallKind, conv: string): void { - onClose(); + if (!embedded) onClose(); onOpenRecalls(kind === 'group' ? 'group' : 'c2c', conv); } + const recordsContent = ( +
+ {summaries.error ? ( + void summaries.refetch()} + /> + ) : summaries.isLoading ? ( +
加载中…
+ ) : recordRows.length === 0 ? ( +
+ + 还没有撤回记录 + 开启防撤回后,被撤回的消息会保留在原位,并在这里按会话汇总。 +
+ ) : ( + recordRows.map((row) => { + const meta = nameById.get(row.conv); + const name = meta?.name ?? row.conv; + const isGroup = row.kind === 'group'; + return ( + + ); + }) + )} +
+ ); + + if (embedded) { + const unavailable = !status.data || !config.data || busy || locked || !active; + const openEditor = (next: Tab) => { + if (unavailable || !(isCurrentSource?.() ?? true)) return; + epoch.current++; + directoryEntry.current = null; + directoryScroll.current = 0; + setSelected(new Set(savedIds)); + setNotifySelected(new Set(notifyIds)); + setEditor(next); + }; + const closeEditor = () => { + epoch.current++; + setEditor(null); + onCloseRecalls?.(); + }; + const choosingProtection = editor === 'protect'; + const draft = choosingProtection + ? dirty + ? [...selected].sort() + : null + : editor === 'notify' && notifyDirty + ? [...notifySelected].sort() + : null; + const title = + editor === 'records' ? '撤回记录' : choosingProtection ? '受保护的会话' : '通知哪些会话'; + return ( +
+ }> + {status.error ? ( + void status.refetch()} + /> + ) : null} + {config.error ? ( + void config.refetch()} + /> + ) : null} + void onToggleEnabled(next)} + label="启用防撤回" + /> + } + /> + + {MODE_OPTIONS.map((option) => ( + + ))} + + } + /> + + {!status.data + ? '尚未读取' + : installedCount + ? `已安装(${installedCount} 张触发器)` + : '未安装'} + + } + /> + openEditor('protect')} + > + + {editor && active && !locked ? ( + + {(requestClose) => ( +
+ + + + ) : undefined + } + actions={ + editor !== 'records' ? ( + + ) : undefined + } + > +
+ {editor === 'records' ? ( + <> + + {showingDetails ? ( +
+ {recallDetails} +
+ ) : null} + + ) : ( + <> + {conversations.error ? ( + void conversations.refetch()} + /> + ) : null} +
+ +
+ + )} +
+
+
+ )} +
+ ) : null} +
+ ); + } + const content = (
void onSaveTargets()} > +
- {homeOpen ? ( - { - setHomeOpen(false); - onClose(); - }} - /> - ) : null} - {markOpen && uin ? ( - { - setMarkOpen(false); - onClose(); - }} - /> - ) : null} - {portraitOpen && canPortrait ? ( - { - setPortraitOpen(false); - onClose(); - }} - /> - ) : null} {analyticsOpen && canAnalyze ? ( { setAnalyticsOpen(false); onClose(); diff --git a/apps/desktop/src/renderer/src/components/MessageDecorationCard.tsx b/apps/desktop/src/renderer/src/components/MessageDecorationCard.tsx index a191a6e6e..576567ec5 100644 --- a/apps/desktop/src/renderer/src/components/MessageDecorationCard.tsx +++ b/apps/desktop/src/renderer/src/components/MessageDecorationCard.tsx @@ -237,8 +237,8 @@ export function MessageDecorationCard({ decoration, onClose }: MessageDecoration {hasDecoration ? ( { @@ -260,7 +260,7 @@ export function MessageDecorationCard({ decoration, onClose }: MessageDecoration { diff --git a/apps/desktop/src/renderer/src/components/MobileWebNavigation.tsx b/apps/desktop/src/renderer/src/components/MobileWebNavigation.tsx index fc1b11eb2..e64812686 100644 --- a/apps/desktop/src/renderer/src/components/MobileWebNavigation.tsx +++ b/apps/desktop/src/renderer/src/components/MobileWebNavigation.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { Bookmark, ChevronRight, @@ -9,10 +9,14 @@ import { LayoutGrid, Mail, MessageCircle, + Plus, Palette, Settings, Sparkles, Star, + UserRoundPen, + UserRoundPlus, + UsersRound, } from 'lucide-react'; import { Avatar } from '../im-template/template/primitives'; import type { MainView, User } from '../im-template/template/types'; @@ -37,6 +41,11 @@ export function MobileWebNavigation({ onOpenSettings, onOpenDressUp, onOpenCollection, + onOpenProfile, + onLookupUser, + onLookupGroup, + onOpenFriendNotices, + onOpenGroupNotices, accountControls, }: { user: User; @@ -55,9 +64,30 @@ export function MobileWebNavigation({ onOpenSettings: () => void; onOpenDressUp: () => void; onOpenCollection: () => void; + onOpenProfile?: () => void; + onLookupUser?: () => void; + onLookupGroup?: () => void; + onOpenFriendNotices?: () => void; + onOpenGroupNotices?: () => void; accountControls?: ReactNode; }) { const [moreOpen, setMoreOpen] = useState(false); + const [actionsOpen, setActionsOpen] = useState(false); + useEffect(() => { + setMoreOpen(false); + setActionsOpen(false); + }, [view, hidden]); + useEffect(() => { + if (!actionsOpen && !moreOpen) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setActionsOpen(false); + setMoreOpen(false); + } + }; + document.addEventListener('keydown', closeOnEscape); + return () => document.removeEventListener('keydown', closeOnEscape); + }, [actionsOpen, moreOpen]); const primary = view === 'messages' || view === 'contacts' || view === 'qzone'; const active = moreOpen || !primary ? 'more' : view; const titles: Partial> = { @@ -73,9 +103,13 @@ export function MobileWebNavigation({ }; const select = (next: MainView) => { setMoreOpen(false); + setActionsOpen(false); onViewChange(next); }; const entries = [ + ...(onOpenProfile + ? [{ label: 'QQ 资料与状态', icon: UserRoundPen, action: onOpenProfile }] + : []), { label: '主页', icon: House, action: () => select('home') }, { label: 'QQ 邮箱', icon: Mail, badge: mailBadgeCount, action: () => select('mail') }, { label: 'QQ 频道', icon: Hash, action: () => select('channel') }, @@ -85,6 +119,17 @@ export function MobileWebNavigation({ { label: '应用', icon: LayoutGrid, action: () => select('tools') }, { label: '设置', icon: Settings, action: onOpenSettings }, ]; + const actions = [ + { label: 'QQ 号搜索', icon: UserRoundPlus, action: onLookupUser }, + { label: '群号搜索', icon: UsersRound, action: onLookupGroup }, + { label: '好友通知', icon: ContactRound, action: onOpenFriendNotices }, + { label: '群通知', icon: UsersRound, action: onOpenGroupNotices }, + ].filter((entry) => entry.action); + const run = (action: () => void) => { + setMoreOpen(false); + setActionsOpen(false); + action(); + }; return ( + + ); + return embedded ? ( + content + ) : ( + + {content} ); } diff --git a/apps/desktop/src/renderer/src/components/QQConversationControls.tsx b/apps/desktop/src/renderer/src/components/QQConversationControls.tsx index eaa049f42..353b3c32e 100644 --- a/apps/desktop/src/renderer/src/components/QQConversationControls.tsx +++ b/apps/desktop/src/renderer/src/components/QQConversationControls.tsx @@ -392,6 +392,7 @@ export function QQConversationControls({ ) : ( } autoSave initial={policySnapshot.data?.rawPolicy ?? null} diff --git a/apps/desktop/src/renderer/src/components/QQWriteControls.tsx b/apps/desktop/src/renderer/src/components/QQWriteControls.tsx index ba277db16..9ddf1ca9d 100644 --- a/apps/desktop/src/renderer/src/components/QQWriteControls.tsx +++ b/apps/desktop/src/renderer/src/components/QQWriteControls.tsx @@ -134,6 +134,7 @@ export function QQWriteSelectRow({ unlistedLabel, autoSave = false, renderEditor, + segmented = false, }: { label: string; icon?: ReactNode; @@ -145,6 +146,7 @@ export function QQWriteSelectRow({ unlistedLabel?: (value: number) => string; autoSave?: boolean; renderEditor?: (value: number | null) => ReactNode; + segmented?: boolean; }) { const [value, setValue] = useState(initial === null ? '' : String(initial)); const previous = useRef(value); @@ -159,6 +161,24 @@ export function QQWriteSelectRow({ previous.current = next; }, [initial]); const editor = renderEditor?.(value === '' ? null : Number(value)); + function choose(next: string) { + if (savingRef.current) return; + setValue(next); + if (autoSave && next !== '' && !renderEditor?.(Number(next))) { + savingRef.current = true; + setSaving(true); + const rollback = () => + setValue(latestInitial.current === null ? '' : String(latestInitial.current)); + void onSave(Number(next)) + .then((ok) => { + if (!ok) rollback(); + }, rollback) + .finally(() => { + savingRef.current = false; + setSaving(false); + }); + } + } return ( <>
- + {segmented ? ( + <> + choose(String(next))} + /> + {!options.some((option) => String(option.value) === value) && ( + + {value ? (unlistedLabel?.(Number(value)) ?? `未识别 (${value})`) : '尚未读取'} + + )} + + ) : ( + + )} {!autoSave && !editor ? (
) : null} @@ -828,6 +841,7 @@ export function QqMarketFace({ data }: { data: Data }) { onClick={() => openMarketFaceLightbox(String(pack), hash)} > (true); export const LinkPreviewContext = createContext(true); /** - * 「渲染消息里的每条装扮(气泡/字体/挂件)」开关(AppSettings.showMsgDecoration)。 + * 消息气泡、字体与头像挂件的独立显示偏好。 * 同上,走 context 是为了避免每条消息各自订阅 getSettings。默认 true。 */ -export const MsgDecorationEnabledContext = createContext(true); +export const MsgDecorationEnabledContext = createContext({ + bubble: true, + font: true, + widget: true, +}); /** Element kinds that render as standalone, borderless media (no bubble). */ const BORDERLESS_MEDIA = new Set(['pic', 'video', 'mface', 'bubbleVideo']); @@ -361,6 +365,60 @@ function trimBoundaryNewline(element: RenderElement, side: 'start' | 'end'): Ren return { ...element, data: { ...(element.data ?? {}), textContent: trimmed } }; } +function MentionNode({ data = {} }: { data?: Record }) { + const open = useContext(ProfileNavigationContext); + const conv = useContext(ConvContext); + const kind = useContext(ForwardKindContext); + const text = String(data.textContent ?? ''); + const uid = typeof data.atTargetUid === 'string' ? data.atTargetUid.trim() : ''; + // 本地 codec 的 45103 与发送回显的 atTargetUin 都是明确的目标 QQ 号。 + const uin = String(data.atTargetUin ?? data.textEncodingFlag ?? ''); + const user = + uid === 'all' || data.all === true + ? null + : profileTargetUser({ identity: uid || uin, uin, name: text.trim().replace(/^@/, '') }); + const clickable = Boolean(open && user); + + function openProfile( + event: ReactMouseEvent | ReactKeyboardEvent, + ) { + if (!open || !user) return; + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + open( + user, + 'key' in event || event.detail === 0 + ? { x: rect.right, y: rect.bottom } + : { x: event.clientX, y: event.clientY }, + kind === 'group' && /^[1-9]\d*$/.test(conv) ? { code: conv, name: '' } : undefined, + ); + } + + return ( + { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openProfile(event); + } + : undefined + } + > + {text} + + ); +} + /** Render one element inline (media component, face, @-mention or text). */ function ElementNode({ element, @@ -383,19 +441,7 @@ function ElementNode({ return ; } if (element.type === 'at') { - const text = String(element.data?.textContent ?? ''); - return ( - - {text} - - ); + return ; } if (element.type === 'markdown') { // QQ 原生 markdownElement。闪传卡片(带 flashTransferInfo)在 QqMessageContent @@ -599,15 +645,7 @@ function ReplyPreviewNode({ ); } if (element.type === 'at') { - const text = String(element.data?.textContent ?? ''); - return ( - - {text} - - ); + return ; } if (element.type && REPLY_MEDIA_LABEL[element.type]) { const name = inlineLabel(element); @@ -688,6 +726,7 @@ function ReplyQuote({ data, sendTimeMs }: { data: Record; sendT const canJump = isUsable(seq) || isUsable(index) || isUsable(clientSeq); function handleJump(event: ReactMouseEvent | ReactKeyboardEvent): void { + event.preventDefault(); event.stopPropagation(); if (canJump) jumpToSeq({ @@ -705,9 +744,23 @@ function ReplyQuote({ data, sendTimeMs }: { data: Record; sendT role={canJump ? 'button' : undefined} tabIndex={canJump ? 0 : undefined} title={canJump ? '跳转到原消息' : undefined} + onClickCapture={(event) => { + // 引用缩略图只负责定位,不启动正文图片灯箱或视频播放器。 + if ( + canJump && + event.target instanceof Element && + event.target.closest('.qq-reply-preview-media') + ) + handleJump(event); + }} onClick={canJump ? handleJump : undefined} onKeyDown={(event) => { - if (canJump && (event.key === 'Enter' || event.key === ' ')) handleJump(event); + if ( + canJump && + event.target === event.currentTarget && + (event.key === 'Enter' || event.key === ' ') + ) + handleJump(event); }} >
@@ -912,19 +965,6 @@ export function QqMessageContent({ ); } - // 私聊「窗口抖动」的**乐观渲染**(`windowShake` 元素):不是灰条 —— 它是**自己发出 - // 的一条消息**,画面就是那枚「戳一戳」超级表情(会轻轻晃动)。收端 QQ 会丢弃 - // serviceType=2 的窗口抖动,所以这一下只有发送方看得见。走 sticker-only:表情本身 - // 不带气泡底板,与真实超级表情一致,位置也落在自己那一侧(见 WindowShakeMessage)。 - const windowShakeElement = elements.find((element) => element.type === 'windowShake'); - if (windowShakeElement) { - return ( -
- -
- ); - } - // 机器人卡片:QQ 把同一条消息同时写成 markdown 正文 + 一串等效的 text/at 元素 // (给不支持 markdown 的老客户端降级用)。两个都渲染会出现重影,所以 markdown // 一旦在场就独占正文,纯文本副本整体丢弃。底部的内联键盘按钮跟在正文后面。 @@ -1150,7 +1190,6 @@ const HANDLED_KINDS = new Set([ 'shareLocation', 'qqDynamic', 'emojiBounce', - 'windowShake', ]); /** diff --git a/apps/desktop/src/renderer/src/components/QqWallet.tsx b/apps/desktop/src/renderer/src/components/QqWallet.tsx index 10046f3a9..b7fc3ffb6 100644 --- a/apps/desktop/src/renderer/src/components/QqWallet.tsx +++ b/apps/desktop/src/renderer/src/components/QqWallet.tsx @@ -225,7 +225,9 @@ export function QqWallet({ {isDesignated ? (
- 给{nick || uin}的专属红包 + + 给{nick || uin}的专属红包 +
) : label ? (
{label}
diff --git a/apps/desktop/src/renderer/src/components/QrImage.tsx b/apps/desktop/src/renderer/src/components/QrImage.tsx index ac624967b..5f62f09fb 100644 --- a/apps/desktop/src/renderer/src/components/QrImage.tsx +++ b/apps/desktop/src/renderer/src/components/QrImage.tsx @@ -51,6 +51,7 @@ export function QrImage({ url, size = 192 }: { url: string; size?: number }): Re ); return ( 二维码 : } {exporting ? '准备中…' : '导出选中相册'} - void exportAlbums(intent)} - />
{albums.map((album) => ( @@ -803,7 +798,13 @@ export function AlbumGridView({ {album.name || '未命名相册'} - {album.mediaCount ?? 0} 个媒体{album.desc ? ` · ${album.desc}` : ''} + {album.mediaCount ?? 0} 个媒体 + {album.desc ? ( + <> + {' · '} + {album.desc} + + ) : null} @@ -1003,7 +1004,7 @@ export function MediaView({ try { const policy = IS_WEB ? await save.prepareSave(intent, { kind: 'bundle', name: albumName }) - : (intent ?? save.policy); + : save.policy; if (!policy || !save.isCurrent()) return; const taskId = await client.account.startQzoneAlbumMediaExport.mutate({ ...binding, @@ -1077,12 +1078,6 @@ export function MediaView({ )} {downloading ? '准备中…' : IS_WEB ? '保存选中到服务器' : '下载选中'} - void downloadSelected(intent)} - />
) : null} {loading ? ( diff --git a/apps/desktop/src/renderer/src/components/RailAccountFooter.tsx b/apps/desktop/src/renderer/src/components/RailAccountFooter.tsx index 0a342e8a7..fa2e8470c 100644 --- a/apps/desktop/src/renderer/src/components/RailAccountFooter.tsx +++ b/apps/desktop/src/renderer/src/components/RailAccountFooter.tsx @@ -1,5 +1,5 @@ /** - * 左栏底部:当前账号大头像 + 右侧弹层(其他账号 / 退出)。 + * 菜单底部应用控制,以及账号切换与退出弹层。 * * 切换/退出都走 closeAccount → 后端 clearAccount 显式停掉 * accountMonitor(QQ 登录实例监听)和 dbWatchHandle(数据库监听), @@ -22,8 +22,6 @@ import { Moon, Sun, Trash2, - Eye, - EyeOff, Camera, Database, Smartphone, @@ -34,7 +32,6 @@ import { useViewState } from '../state/view'; import { leaveAccountForBootstrap, purgeAccountQueries } from '../lib/leaveAccount'; import { useAppLock } from '../state/lock'; import { useThemeStore } from '../state/theme'; -import { usePrivacyStore } from '../state/privacy'; import { useAccountSwitch } from '../state/accountSwitch'; import { confirmMailAccountLeave } from '../state/mailDraft'; import { runAccountWarmup } from '../lib/accountWarmup'; @@ -48,35 +45,51 @@ import { useDialog } from './Dialog'; import { useToast } from './Toast'; import { QqAvatar } from './QqAvatar'; import { DesktopOnly, shellBridge } from '../lib/target'; -import { RailUtilityDock } from './RailUtilityDock'; import '../styles/rail-account.css'; +import { useWorkspaceMenu } from '../state/workspaceMenu'; +import { useMenuCapture } from '../hooks/useMenuCapture'; +import { captureMenuScreenshot } from '../lib/menuScreenshot'; +import '../styles/menu-capture.css'; function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); } +function MenuCaptureButton({ onCapture }: { onCapture: () => Promise }): ReactElement { + const menuCapture = useMenuCapture(onCapture); + return ( + + ); +} + export function RailAccountFooter({ currentUin, currentName, currentAvatarUrl, + settingsButton, children, }: { currentUin: string; currentName: string; currentAvatarUrl?: string | null; - children?: ReactNode; + settingsButton?: ReactNode; + children?: ReactNode | ((controls: ReactNode) => ReactNode); }): ReactElement { const setOpenedUin = useViewState((s) => s.setOpenedUin); const goTo = useViewState((s) => s.goTo); const showError = useDialog((s) => s.showError); const lock = useAppLock((s) => s.lock); - // Manual 深/浅 toggle sitting above the lock button. Reuses the global theme - // store — flipping here also updates the settings page (跟随系统 can still be - // chosen there; a click just pins one concrete mode). + // 菜单与设置共用主题状态;快捷切换固定为浅/深色,跟随系统仍在设置里选择。 const resolvedTheme = useThemeStore((s) => s.resolved); const setThemePreference = useThemeStore((s) => s.setPreference); - const privacyEnabled = usePrivacyStore((s) => s.enabled); - const togglePrivacy = usePrivacyStore((s) => s.toggle); const pushToast = useToast((s) => s.push); const queryClient = useQueryClient(); const utils = trpc.useUtils(); @@ -88,6 +101,7 @@ export function RailAccountFooter({ const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const wrapRef = useRef(null); + const capturePending = useRef(false); // 应用锁配置(解锁方式 / 总开关),供上锁按钮做可上锁判断。 const lockSettings = trpc.bootstrap.getSettings.useQuery(undefined, { @@ -118,14 +132,14 @@ export function RailAccountFooter({ /** 按钮禁用时的提示原因;null = 当前可上锁。 */ function lockBlockReason(): string | null { - if (appLock?.enabled === false) return '应用锁已关闭,请在 设置 → 应用锁 中开启。'; + if (appLock?.enabled === false) return '应用锁已关闭,请在 设置 → 常规 → 隐私 中开启。'; if (lockMethod === 'system') { return systemAuthQuery.data?.available === false ? (systemAuthQuery.data?.error ?? '当前设备系统认证不可用。') : null; } return totpQuery.data?.configured === false - ? '验证器未绑定,请先在 设置 → 应用锁 中完成绑定。' + ? '验证器未绑定,请先在 设置 → 常规 → 隐私 中完成绑定。' : null; } @@ -135,7 +149,7 @@ export function RailAccountFooter({ const fresh = await lockSettings.refetch(); const cfg = fresh.data?.appLock; if (cfg?.enabled === false) { - showError('无法上锁', '应用锁已关闭,请在 设置 → 应用锁 中开启。'); + showError('无法上锁', '应用锁已关闭,请在 设置 → 常规 → 隐私 中开启。'); return; } const method = cfg?.method ?? 'totp'; @@ -150,7 +164,7 @@ export function RailAccountFooter({ const bridge = shellBridge(); const status = bridge ? await bridge.totp.getStatus() : null; if (!status?.configured) { - showError('无法上锁', '验证器未绑定,请先在 设置 → 应用锁 中完成绑定。'); + showError('无法上锁', '验证器未绑定,请先在 设置 → 常规 → 隐私 中完成绑定。'); return; } } @@ -158,6 +172,7 @@ export function RailAccountFooter({ // 读取失败时照常上锁,解锁弹窗会自己校验当前配置。 } setOpen(false); + useWorkspaceMenu.getState().close(); lock(); } @@ -168,8 +183,18 @@ export function RailAccountFooter({ } async function captureNow(): Promise { + if (capturePending.current) return; + const bridge = shellBridge(); + if (!bridge) return; + capturePending.current = true; try { - const res = await window.weq.capture.window(); + const res = await captureMenuScreenshot( + () => bridge.capture.window(), + () => { + setOpen(false); + useWorkspaceMenu.getState().close(); + }, + ); if (res.ok) { pushToast({ tone: 'success', title: '截图已复制', message: '窗口截图已写入剪贴板' }); } else { @@ -177,6 +202,8 @@ export function RailAccountFooter({ } } catch (e) { showError('截图失败', errMsg(e)); + } finally { + capturePending.current = false; } } @@ -334,61 +361,22 @@ export function RailAccountFooter({ currentConfigId ? c.configId !== currentConfigId : c.uin !== currentUin, ); - return ( - <> - - - - - + const controls = ( +
+ {settingsButton} + {typeof children === 'function' ? null : children} + - - - - - {children} +
- {open ? ( - setOpen(false)} - > -
-
- 切换账号 +
+ + + + +
+ ); + + return ( + <> + {typeof children === 'function' ? children(controls) : controls} + {open ? ( + setOpen(false)} + > +
+
+ 切换账号 +
+ {configs.error ? ( +
+ 账号列表读取失败:{configs.error.message} + + {configs.data?.length ? 以下为上次读取的记录。 : null}
- {configs.error ? ( -
- 账号列表读取失败:{configs.error.message} - - {configs.data?.length ? 以下为上次读取的记录。 : null} -
- ) : null} - {configs.isLoading ? ( -
加载中…
- ) : others.length === 0 ? ( - configs.error ? null : ( -
暂无其它账号
- ) - ) : ( -
    - {others.map((cfg) => ( -
  • - -
  • - ))} -
- )} - -
-
- ) : null} - {ctxMenu && ( - {cfg.uin} + + + + ))} + + )} + + + + ) : null} + {ctxMenu && ( + +
e.stopPropagation()} > -
e.stopPropagation()} + -
- - )} -
+ + 删除账号 + +
+ + )} ); } diff --git a/apps/desktop/src/renderer/src/components/RedBagDetailDialog.tsx b/apps/desktop/src/renderer/src/components/RedBagDetailDialog.tsx index 9c7922029..469ab72d4 100644 --- a/apps/desktop/src/renderer/src/components/RedBagDetailDialog.tsx +++ b/apps/desktop/src/renderer/src/components/RedBagDetailDialog.tsx @@ -306,7 +306,8 @@ function RedBagDetailDialog({ {detail?.totalAmount ? ( - {detail.lucky ? '总金额' : '金额'} ¥{formatAmount(detail.totalAmount)} + {detail.lucky ? '总金额' : '金额'}{' '} + ¥{formatAmount(detail.totalAmount)} ) : null} diff --git a/apps/desktop/src/renderer/src/components/RendererErrorBoundary.tsx b/apps/desktop/src/renderer/src/components/RendererErrorBoundary.tsx new file mode 100644 index 000000000..1bcfea5b7 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/RendererErrorBoundary.tsx @@ -0,0 +1,36 @@ +import { Component, type ReactNode } from 'react'; +import { RefreshCw, TriangleAlert } from 'lucide-react'; +import '../styles/renderer-recovery.css'; + +/** 放在业务 Provider 外,恢复界面不依赖已经抛错的状态或 IPC。 */ +export class RendererErrorBoundary extends Component< + { children: ReactNode; reload?: () => void }, + { failed: boolean } +> { + override state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + override render() { + if (!this.state.failed) return this.props.children; + return ( +
+
+ +

界面遇到异常

+

后台操作可能仍在运行,请先核对结果,避免重复提交。

+

重新加载会丢失未保存的界面内容,不会自动重发操作。

+ +
+
+ ); + } +} diff --git a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx index 661d7fbb2..572aded6d 100644 --- a/apps/desktop/src/renderer/src/components/RestrictedMode.tsx +++ b/apps/desktop/src/renderer/src/components/RestrictedMode.tsx @@ -1,5 +1,6 @@ -import { useEffect, useId, useRef, useState } from 'react'; -import { KeyRound, RefreshCw, ShieldAlert, X } from 'lucide-react'; +import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; +import { KeyRound, LoaderCircle, Play, RefreshCw, ShieldAlert, X } from 'lucide-react'; +import { LongTermAuthorizationLink } from './LongTermAuthorizationLink'; import { client, trpc } from '../trpc/client'; import { restrictionReasons } from '../lib/restrictedMode'; import { useRestrictedMode, type AuthorizationRequest } from '../state/restrictedMode'; @@ -7,6 +8,8 @@ import { useViewState } from '../state/view'; import { useToast } from './Toast'; import '../styles/restricted-mode.css'; +const NOTICE_KEY = 'restricted-mode'; + function ipc() { return window.electron?.ipcRenderer; } @@ -54,22 +57,42 @@ function useAuthorizationBridge() { }, []); } -export function RestrictedModeIndicator({ startup = false }: { startup?: boolean }) { +export function RestrictedModeIndicator({ + startup = false, + persistent = false, +}: { + startup?: boolean; + persistent?: boolean; +}) { const reasons = useRestrictedMode((s) => s.reasons); const requests = useRestrictedMode((s) => s.requests); const open = useRestrictedMode((s) => s.open); - if (!reasons.length && !requests.length) return null; + const entering = useRestrictedMode((s) => s.enteringSince !== null && s.requests.length === 0); + const awaitingAuthorization = requests.length > 0; + const title = awaitingAuthorization ? '待提权' : entering ? '正在启用完整功能' : '受限模式'; + const visible = Boolean(reasons.length || requests.length || entering); + if (!visible && !persistent) return null; return ( ); } @@ -81,10 +104,29 @@ export function cancelAuthorizationRequest(request: AuthorizationRequest): void useRestrictedMode.getState().remove(request.kind, request.token); } -export function AuthorizationForm({ request }: { request: AuthorizationRequest }) { +export function AuthorizationForm({ + request, + onOpenLongTermAuthorization, + actions, +}: { + request: AuthorizationRequest; + onOpenLongTermAuthorization?: () => void; + actions?: ReactNode; +}) { const [password, setPassword] = useState(''); const id = useId(); function respond(cancel = false, retry = false) { + if ( + !useRestrictedMode + .getState() + .requests.some((item) => item.kind === request.kind && item.token === request.token) + ) { + setPassword(''); + return; + } + const view = useViewState.getState(); + if (!cancel && view.view === 'main' && view.openedUin) + useRestrictedMode.getState().beginEntering(); if (request.kind === 'password') ipc()?.send('elev:respond-password', { token: request.token, @@ -128,9 +170,16 @@ export function AuthorizationForm({ request }: { request: AuthorizationRequest } 重试免密读取 )} - + {!request.title.includes('长期授权') && ( + { + respond(true); + useRestrictedMode.getState().setOpen(false); + onOpenLongTermAuthorization?.(); + }} + /> + )} + {actions} ); @@ -142,16 +191,30 @@ export function RestrictedModeHost() { const view = useViewState((s) => s.view); const active = view === 'main' && Boolean(uin); const reasons = useRestrictedMode((s) => s.reasons); + const reasonSource = useRestrictedMode((s) => s.reasonSource); const requests = useRestrictedMode((s) => s.requests); const open = useRestrictedMode((s) => s.open); + const enteringSince = useRestrictedMode((s) => s.enteringSince); + const awaitingAuthorization = requests.length > 0; + const entering = enteringSince !== null && !awaitingAuthorization; + const previousUin = useRef(uin); const panel = useRef(null); const previous = useRef(''); const recoveryPending = useRef(false); - const notice = useRef(undefined); const titleId = useId(); const [busy, setBusy] = useState(false); + const [launching, setLaunching] = useState(false); const [error, setError] = useState(''); + const operation = useRef(0); + const operationBusy = useRef(false); + const launchOperation = useRef(0); + const launchPending = useRef(false); const utils = trpc.useUtils(); + const globalSettings = trpc.bootstrap.getSettings.useQuery(undefined, { + staleTime: 0, + refetchOnWindowFocus: true, + }); + const passiveBanner = globalSettings.data?.restrictedModeBannerEnabled !== false; useEffect(() => { if (!active) return; const subscription = client.account.onOnlineCredentialsReady.subscribe(undefined, { @@ -168,6 +231,40 @@ export function RestrictedModeHost() { staleTime: 4000, refetchOnWindowFocus: true, }); + const accessSource = access.data?.source; + const accessMatches = !accessSource || accessSource.expectedAccountUin === uin; + const sourceKey = JSON.stringify([ + active, + uin, + accessSource?.expectedAccountUin, + accessSource?.expectedAccountConfigId, + ]); + const currentSource = useRef(sourceKey); + const previousSource = useRef(sourceKey); + currentSource.current = sourceKey; + useEffect(() => { + if (previousSource.current === sourceKey) return; + previousSource.current = sourceKey; + operation.current += 1; + launchOperation.current += 1; + operationBusy.current = false; + launchPending.current = false; + setBusy(false); + setLaunching(false); + setError(''); + previous.current = ''; + recoveryPending.current = false; + useToast.getState().resolve(NOTICE_KEY); + useRestrictedMode.getState().setOpen(false); + useRestrictedMode.getState().endEntering(); + }, [sourceKey]); + useEffect( + () => () => { + operation.current += 1; + launchOperation.current += 1; + }, + [], + ); // 由运行 QQ 的宿主判断系统;Web 客户端的操作系统不能代表宿主。 const sip = trpc.bootstrap.sipEnabled.useQuery(undefined, { staleTime: 60_000 }); const online = trpc.bootstrap.probeOnline.useQuery(undefined, { @@ -176,7 +273,11 @@ export function RestrictedModeHost() { }); useEffect(() => { const next = active - ? restrictionReasons({ access: access.data, sipEnabled: sip.data, failed: access.isError }) + ? restrictionReasons({ + access: accessMatches ? access.data : undefined, + sipEnabled: sip.data, + failed: access.isError || !accessMatches, + }) : restrictionReasons({ access: online.data?.count === 0 @@ -191,51 +292,124 @@ export function RestrictedModeHost() { : undefined, failed: online.isError, }); - useRestrictedMode.getState().setReasons(next); - }, [active, uin, access.data, access.isError, sip.data, online.data, online.isError]); - const signature = [ - ...reasons.map((r) => r.code), - ...(requests.length ? ['authorization'] : []), - ].join(','); + useRestrictedMode.getState().setReasons(next, sourceKey); + }, [ + active, + uin, + access.data, + access.isError, + accessMatches, + sip.data, + online.data, + online.isError, + sourceKey, + ]); const fullModeConfirmed = active ? Boolean( - access.data && + accessMatches && + access.data && !access.isError && restrictionReasons({ access: access.data, sipEnabled: sip.data }).length === 0, ) - : Boolean(online.data && !online.isError && online.data.count > 0); + : false; + useEffect(() => { + if (!active) return; + const blocked = + !accessMatches || + !access.data?.qqOnline || + !access.data.attachEnabled || + access.isError || + restrictionReasons({ access: access.data, sipEnabled: sip.data }).some( + (reason) => reason.code === 'sip', + ); + if (access.data?.credentialsPending && !fullModeConfirmed && !blocked) { + if (enteringSince === null && !requests.length) useRestrictedMode.getState().beginEntering(); + return; + } + // 提交授权后必须拿到新查询结果,不能用之前缓存的完整状态提前结束。 + const checkedAt = Math.max(access.dataUpdatedAt, access.errorUpdatedAt ?? 0); + if (enteringSince !== null && !busy && !requests.length && checkedAt > enteringSince) + useRestrictedMode.getState().endEntering(); + }, [ + active, + accessMatches, + access.data, + access.isError, + access.dataUpdatedAt, + access.errorUpdatedAt, + sip.data, + fullModeConfirmed, + enteringSince, + busy, + requests.length, + ]); useEffect(() => { + if (active && enteringSince !== null) void utils.account.getGroupAlbumAccessState.invalidate(); + }, [active, uin, enteringSince, utils]); + const signature = [ + ...reasons.map((r) => r.code), + ...(requests.length ? ['authorization'] : []), + ...(entering ? ['entering'] : []), + ].join(','); + useEffect(() => { + if (reasonSource !== sourceKey) return; + const activeNotice = entering || awaitingAuthorization; + if (globalSettings.isLoading && !activeNotice) return; + if (!activeNotice && !passiveBanner) useToast.getState().resolve(NOTICE_KEY); if (signature) recoveryPending.current = true; - if (signature && signature !== previous.current) { + if (signature && signature !== previous.current && (activeNotice || passiveBanner)) { const toast = useToast.getState(); - if (notice.current !== undefined) toast.dismiss(notice.current); - notice.current = toast.push({ - tone: 'warning', - title: `已进入受限模式:${requests.length ? '待提权' : reasons.map((r) => r.title).join('、')}`, + const message: Parameters[0] = { + key: NOTICE_KEY, + tone: entering ? 'info' : 'warning', + title: awaitingAuthorization + ? '待提权:请完成管理员授权' + : entering + ? '正在启用完整功能' + : `已进入受限模式:${reasons.map((r) => r.title).join('、')}`, + loading: entering, + durationKind: entering ? 'long' : 'failure', + ...(entering ? { ttl: 0 } : {}), action: { - label: '查看受限模式详情', + label: entering ? '查看进入进度' : '查看受限模式详情', run: () => { if ( useRestrictedMode.getState().reasons.length || - useRestrictedMode.getState().requests.length + useRestrictedMode.getState().requests.length || + useRestrictedMode.getState().enteringSince !== null ) useRestrictedMode.getState().setOpen(true); }, }, - }); + }; + toast.push(message); } if (!signature) { useRestrictedMode.getState().setOpen(false); - if (notice.current !== undefined) useToast.getState().dismiss(notice.current); - notice.current = undefined; + useToast.getState().resolve(NOTICE_KEY); if (recoveryPending.current && fullModeConfirmed) { - useToast.getState().push({ tone: 'success', title: '已恢复完整模式' }); + useToast.getState().push({ tone: 'success', title: '已启用完整功能' }); recoveryPending.current = false; } } previous.current = signature; - }, [signature, reasons, requests, fullModeConfirmed]); + }, [ + signature, + reasons, + requests, + fullModeConfirmed, + entering, + awaitingAuthorization, + passiveBanner, + globalSettings.isLoading, + reasonSource, + sourceKey, + ]); useEffect(() => { + if (previousUin.current !== uin) { + previousUin.current = uin; + useRestrictedMode.getState().endEntering(); + } setError(''); useRestrictedMode.getState().setOpen(false); }, [uin]); @@ -262,22 +436,105 @@ export function RestrictedModeHost() { if (before instanceof HTMLElement && before.isConnected) before.focus(); }; }, [open]); + async function startQq() { + if (launchPending.current) return; + launchPending.current = true; + const id = ++launchOperation.current, + binding = sourceKey; + const current = () => launchOperation.current === id && currentSource.current === binding; + setLaunching(true); + setError(''); + try { + await client.bootstrap.launchQq.mutate(); + if (!current()) return; + await (active ? access.refetch() : online.refetch()); + } catch (e) { + if (current()) setError(e instanceof Error ? e.message : String(e)); + } finally { + if (current()) { + launchPending.current = false; + setLaunching(false); + } + } + } async function refresh(enable = false) { + if (operationBusy.current) return; + const source = access.data?.source ? { ...access.data.source } : undefined; + if (enable && (!active || !source || source.expectedAccountUin !== uin)) { + setError('当前来源尚未确认,请重新检查后再启用完整功能。'); + return; + } + operationBusy.current = true; + const id = ++operation.current; + const binding = sourceKey; + const current = () => operation.current === id && currentSource.current === binding; setBusy(true); setError(''); + if (active) useRestrictedMode.getState().beginEntering(); try { - if (enable) await client.bootstrap.setAutoAttachQq.mutate({ enabled: true }); - if (active && (enable || (access.data?.qqOnline && access.data.attachEnabled))) { - const refreshed = await client.account.refreshRkeys.mutate(); + if (enable && source) await client.account.requestFullMode.mutate(source); + if (!current()) return; + if ( + active && + accessMatches && + (enable || (access.data?.qqOnline && access.data.attachEnabled)) + ) { + const refreshed = await client.account.refreshRkeys.mutate(source); + if (!current()) return; if (!refreshed) setError('尚未恢复完整功能,请检查 QQ 登录状态及授权请求后重试。'); } await Promise.all([active ? access.refetch() : online.refetch(), sip.refetch()]); } catch (e) { + if (!current()) return; setError(e instanceof Error ? e.message : String(e)); + useRestrictedMode.getState().endEntering(); } finally { - setBusy(false); + if (current()) { + operationBusy.current = false; + setBusy(false); + } } } + const actions = ( + <> + {reasons.some((reason) => reason.code === 'offline') && ( + + )} + + {reasons.some((r) => r.code === 'disabled') && ( + + )} + + ); return ( <> {!active && } @@ -287,12 +544,18 @@ export function RestrictedModeHost() { role="dialog" aria-modal="false" aria-labelledby={titleId} - className="weq-restricted-panel" + className={`weq-restricted-panel${entering ? ' is-entering' : ''}`} >

- - 受限模式 + {awaitingAuthorization ? ( + + ) : entering ? ( + + ) : ( + + )} + {awaitingAuthorization ? '待提权' : entering ? '正在启用完整功能' : '受限模式'}

- {reasons.map((reason) => ( -
-

{reason.title}

-

{reason.guidance}

-
- ))} - {requests.map((request) => ( - + {entering &&

正在准备在线会话,请稍候。

} + {reasons + .filter((reason) => !awaitingAuthorization || reason.code !== 'credentials') + .map((reason) => ( +
+

{reason.title}

+

{reason.guidance}

+
+ ))} + {requests.map((request, index) => ( + ))} {error &&

{error}

} -
- - {reasons.some((r) => r.code === 'disabled') && ( - - )} -
+ {!awaitingAuthorization &&
{actions}
}
)} diff --git a/apps/desktop/src/renderer/src/components/RetainedTabs.tsx b/apps/desktop/src/renderer/src/components/RetainedTabs.tsx index c34830348..ae80d7479 100644 --- a/apps/desktop/src/renderer/src/components/RetainedTabs.tsx +++ b/apps/desktop/src/renderer/src/components/RetainedTabs.tsx @@ -28,9 +28,11 @@ function RetainedPanel({ children: ReactNode; scrollable: boolean; }) { + const parentActive = useTabActive(); + const effectiveActive = parentActive && active; const scrolls = useRef(new Map()); useLayoutEffect(() => { - if (!active) return; + if (!effectiveActive) return; for (const [element, position] of scrolls.current) { if (!element.isConnected) { scrolls.current.delete(element); @@ -41,9 +43,9 @@ function RetainedPanel({ element.scrollTop = position.top; element.scrollLeft = position.left; } - }, [active]); + }, [effectiveActive]); return ( - + - + ); } diff --git a/apps/desktop/src/renderer/src/components/ReverseTool.tsx b/apps/desktop/src/renderer/src/components/ReverseTool.tsx index 925ba8f57..89579bbaa 100644 --- a/apps/desktop/src/renderer/src/components/ReverseTool.tsx +++ b/apps/desktop/src/renderer/src/components/ReverseTool.tsx @@ -20,6 +20,7 @@ import { type ReactElement, } from 'react'; import { Check, Copy, Eraser, Play } from 'lucide-react'; +import { useDraftProtection } from '../lib/draftClose'; import { lookupTag } from '@weq/codec/dictionary'; import { bytesToBase64, @@ -168,6 +169,7 @@ export function ReverseTool(): ReactElement { const [copied, setCopied] = useState(false); const [parseSeq, setParseSeq] = useState(0); const [detail, setDetail] = useState(false); + useDraftProtection({ input, encoding, format }); const runParse = useCallback((text: string, enc: RvEncoding, fmt: ParseFormat) => { try { diff --git a/apps/desktop/src/renderer/src/components/RoamingBackfillPanel.tsx b/apps/desktop/src/renderer/src/components/RoamingBackfillPanel.tsx index 74968ceb1..fd39e0269 100644 --- a/apps/desktop/src/renderer/src/components/RoamingBackfillPanel.tsx +++ b/apps/desktop/src/renderer/src/components/RoamingBackfillPanel.tsx @@ -461,7 +461,8 @@ export function RoamingBackfillPanel({ {target.name} - {target.kind === 'group' ? '群聊' : '私聊'} · {target.conv} + {target.kind === 'group' ? '群聊' : '私聊'} ·{' '} + {target.conv} {info?.messageCount ? ` · ${format(info.messageCount)} 条` : ''} @@ -886,10 +887,12 @@ export function RoamingBackfillPanel({ {[...job.attempts].reverse().map((attempt) => (
- {targetNames.get(attempt.target) ?? - job.targets.find((target) => targetKey(target) === attempt.target) - ?.name ?? - attempt.target} + + {targetNames.get(attempt.target) ?? + job.targets.find((target) => targetKey(target) === attempt.target) + ?.name ?? + attempt.target} + seq {attempt.start}–{attempt.end} diff --git a/apps/desktop/src/renderer/src/components/SegmentedControl.tsx b/apps/desktop/src/renderer/src/components/SegmentedControl.tsx new file mode 100644 index 000000000..f483c7aed --- /dev/null +++ b/apps/desktop/src/renderer/src/components/SegmentedControl.tsx @@ -0,0 +1,64 @@ +import { useId, useRef, type ReactNode } from 'react'; +import '../styles/segmented-control.css'; + +/** 少量互斥选项共用;禁用项不参与方向键导航,未知值不冒充已选择。 */ +export function SegmentedControl({ + label, + value, + options, + onChange, + disabled = false, +}: { + label: string; + value: T | null; + options: readonly { value: T; label: ReactNode; disabled?: boolean; title?: string }[]; + onChange: (value: T) => void; + disabled?: boolean; +}) { + const id = useId(); + const group = useRef(null); + const enabled = options.filter((option) => !disabled && !option.disabled); + const tabValue = enabled.find((option) => option.value === value)?.value ?? enabled[0]?.value; + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/SequentialForwardDialog.tsx b/apps/desktop/src/renderer/src/components/SequentialForwardDialog.tsx index 51812beeb..1c35006fa 100644 --- a/apps/desktop/src/renderer/src/components/SequentialForwardDialog.tsx +++ b/apps/desktop/src/renderer/src/components/SequentialForwardDialog.tsx @@ -177,24 +177,16 @@ export function SequentialForwardDialog({ <> {options.mode === 'text' ? (
@@ -357,3 +346,4 @@ export function SequentialForwardDialog({ ); } +import { SegmentedControl } from './SegmentedControl'; diff --git a/apps/desktop/src/renderer/src/components/SettingsDialog.tsx b/apps/desktop/src/renderer/src/components/SettingsDialog.tsx index 41ccf3dbe..880658319 100644 --- a/apps/desktop/src/renderer/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsDialog.tsx @@ -1,12 +1,12 @@ /** * 设置整页。 * - * 左侧是分类导航,右侧是分类内容。外观能力单独收敛到“个性显示”页: - * 基础模式、主题色(色相条 + 预设 + Hex)、界面背景、组件风格(占位)。 + * 左侧一级分类,右侧二级页;旧页面与条目入口统一解析到规范位置。 */ import { useEffect, + useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, @@ -22,11 +22,11 @@ import { BrainCircuit, Check, ChevronRight, - CloudDownload, DatabaseZap, Globe, HardDrive, HelpCircle, + House, Lock, LockKeyhole, Mail, @@ -41,19 +41,20 @@ import { Settings2, ScanSearch, ScrollText, - ShieldCheck, Sun, User, Users, X, } from 'lucide-react'; import { GlobalSettingsSection } from './settings/GlobalSettingsSection'; +import { HomeSettingsSection } from './settings/HomeSettingsSection'; import { HelpSection } from './settings/HelpSection'; import { VersionSection } from './settings/VersionSection'; import { QzoneSettingsSection } from './settings/QzoneSettingsSection'; import { CallRecordingSection } from './settings/CallRecordingSection'; import { MailSettingsSection } from './settings/MailSettingsSection'; -import { AppLockSection } from './settings/AppLockSection'; +import { PrivacySection } from './settings/PrivacySection'; +import { TypingStatusSettingsCard } from './settings/TypingStatusSettingsCard'; import { AccountBasicsSection } from './settings/AccountBasicsSection'; import { AgentLabSection } from './settings/AgentLabSection'; import { IntentSenseSection } from './settings/IntentSenseSection'; @@ -66,24 +67,36 @@ import { RemoteSection } from './settings/RemoteSection'; import { BackupSection } from './settings/BackupSection'; import { ExternalRkeySection } from './settings/ExternalRkeySection'; import { SsePushSection } from './settings/SsePushSection'; -import { DaemonSection } from './settings/DaemonSection'; -import { DatabaseToleranceSection } from './settings/DatabaseToleranceSection'; +import { AssistantSection } from './settings/AssistantSection'; +import { StartupSection } from './settings/StartupSection'; +import { DatabaseSettingsSection } from './settings/DatabaseSettingsSection'; import { DataMaintenanceSection, type MaintenanceTool } from './settings/DataMaintenanceSection'; -import { SectionHeader, SettingsScopeContext, Toggle } from './settings/controls'; +import { DatabaseManagementSection } from './settings/DatabaseManagementSection'; +import { ResourceManagementSection } from './settings/ResourceManagementSection'; +import { ChatImageCacheSettings } from './settings/ChatImageCacheSettings'; +import { Card, Row, SectionHeader, SettingsScopeContext, Toggle } from './settings/controls'; import { trpc } from '../trpc/client'; import { DEFAULT_ACCENT, normalizeHex } from '../../../shared/theme_colors'; import { AntiRecallDialog } from './AntiRecallDialog'; import { WorkspacePage } from './WorkspacePages'; +import { LoginAccountPanel } from './LoginAccountPanel'; +import { ReverseTool } from './ReverseTool'; import { IS_WEB } from '../lib/target'; import { UsagePanel } from '../views/agentlab/UsagePanel'; import '../styles/settings-responsive.css'; import '../styles/settings-navigation.css'; +import '../styles/app-search.css'; +import type { SettingsSearchTarget } from './settings/searchTarget'; +import { RetainedTabs, TabActivity, useTabActive } from './RetainedTabs'; +import { RetainedSettingsPage } from './settings/RetainedSettingsPage'; +import { useAppLock } from '../state/lock'; import { SETTINGS_GROUPS, + resolveSettingsTarget, settingsGroupFor, settingsScopeFor, - settingsSectionFor, type SectionId, + type SettingsPageId, } from './settings/navigation'; export type { SectionId } from './settings/navigation'; import { @@ -92,10 +105,11 @@ import { type ThemeComponentStyle, type ThemePreference, } from '../state/theme'; +import { useUpdateStore } from '../state/update'; /** 左侧导航的分区 id。导出出去是为了让别的界面能"直接打开某一屏"(例如损坏弹窗 → 数据库宽容)。 */ interface SettingsSection { - id: SectionId; + id: SettingsPageId; label: string; icon: ReactElement; render: ( @@ -105,15 +119,16 @@ interface SettingsSection { } const SETTINGS_SECTIONS: SettingsSection[] = [ + { id: 'home', label: '主页', icon: , render: () => }, { id: 'backup', - label: '备份与恢复', + label: '配置备份与恢复', icon: , render: () => , }, { id: 'global', - label: '窗口行为', + label: '提醒与窗口', icon: , render: () => , }, @@ -130,39 +145,42 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ render: () => , }, { - id: 'applock', - label: '应用锁', + id: 'privacy', + label: '隐私', icon: , - render: () => , + render: () => , }, { id: 'account', - label: '接入与监听', + label: '监听与读取', icon: , render: () => , }, { id: 'accounts', - label: '账号管理', + label: 'WeQ 账号管理', icon: , - render: (onOpen) => ( + render: () => ( <> - + ), }, - { id: 'anti-recall', label: '防撤回', icon: , render: () => null }, + { + id: 'qq-accounts', + label: 'QQ 账号管理', + icon: , + render: () => ( +
+ } /> + +
+ ), + }, { id: 'call-recording', - label: '通话与录音', + label: '通话录音', icon: , render: () => , }, @@ -179,22 +197,28 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ render: () => , }, { - id: 'database', - label: '损坏处理与容错', + id: 'database-repair', + label: '损坏与修复', icon: , - render: () => , + render: (onOpen) => , }, { - id: 'maintenance', - label: '维护工具', + id: 'database-management', + label: '数据库管理', icon: , - render: (onOpen) => , + render: () => , }, { - id: 'database-status', - label: '容错记录', + id: 'database-tolerance', + label: '数据库宽容', icon: , - render: () => , + render: (onOpen) => , + }, + { + id: 'qq-storage', + label: '资源目录管理', + icon: , + render: () => , }, { id: 'voice', @@ -202,25 +226,34 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ icon: , render: () => , }, + { + id: 'resource-loading', + label: '资源加载', + icon: , + render: () => , + }, { id: 'diagnostics', - label: '高级诊断', + label: '诊断工具', icon: , render: (onOpen) => (
- + } />
- -
+ + +
), }, @@ -288,10 +321,10 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ render: () => , }, { - id: 'rkey', - label: '外部 RKEY', - icon: , - render: () => , + id: 'assistant', + label: 'QQ 公众号推送', + icon: , + render: () => , }, { id: 'sse', @@ -299,12 +332,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ icon: , render: () => , }, - { - id: 'daemon', - label: '后台服务', - icon: , - render: () => , - }, { id: 'faq', label: '常见问题', @@ -318,8 +345,8 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ render: () => , }, { - id: 'message-display', - label: '消息显示', + id: 'chat-messages', + label: '聊天消息', icon: , render: () => , }, @@ -327,31 +354,39 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ id: 'startup', label: '开机启动', icon: , - render: () => , + render: () => , }, { - id: 'saving', - label: '默认保存', + id: 'weq-storage', + label: 'WeQ 存储管理', icon: , - render: () => , - }, - { - id: 'storage', - label: '存储与网络', - icon: , - render: () => , - }, - { - id: 'media', - label: '媒体来源', - icon: , - render: () => , + render: () => ( + <> +
+ } /> +
+ + + + ), }, { id: 'credentials', - label: '账号凭据', + label: '凭据管理', icon: , - render: () => , + render: (onOpen) => ( + <> + + +
+ +
+ + ), }, { id: 'logs', @@ -378,8 +413,12 @@ export function SettingsDialog({ open, onClose, initialSection, + searchTarget, onOpenMaintenance, onOpenRecalls, + recallDetails, + onCloseRecalls, + isCurrentSource, onCreateClone, }: { open: boolean; @@ -389,24 +428,34 @@ export function SettingsDialog({ * 否则"先关掉、再从损坏弹窗点进来"会停在用户上次看的那个分区上。 */ initialSection?: SectionId; + searchTarget?: SettingsSearchTarget | null; onOpenMaintenance: (tool: MaintenanceTool) => void; onOpenRecalls: (kind: 'c2c' | 'group', conv: string) => void; + recallDetails?: ReactNode; + onCloseRecalls?: () => void; + isCurrentSource?: () => boolean; onCreateClone?: () => void; }): ReactElement | null { - const [activeId, setActiveId] = useState(() => - settingsSectionFor(initialSection ?? 'global'), + const target = useMemo( + () => resolveSettingsTarget(searchTarget ?? { section: initialSection ?? 'appearance' }), + [initialSection, searchTarget], ); + const [activeId, setActiveId] = useState(() => target.section); + const updateAvailable = useUpdateStore((state) => state.available); + const locked = useAppLock((state) => state.locked); const navRef = useRef(null); - const [mobileCategoryOpen, setMobileCategoryOpen] = useState(Boolean(initialSection)); + const [mobileCategoryOpen, setMobileCategoryOpen] = useState( + Boolean(initialSection || searchTarget), + ); const subnavRef = useRef(null); const sections = SETTINGS_SECTIONS; const group = settingsGroupFor(activeId); const groupSections = group.sections.map((id) => sections.find((section) => section.id === id)!); useEffect(() => { - if (open && initialSection) setActiveId(settingsSectionFor(initialSection)); - if (open) setMobileCategoryOpen(Boolean(initialSection)); - }, [open, initialSection]); + if (open) setActiveId(target.section); + if (open) setMobileCategoryOpen(Boolean(initialSection || searchTarget)); + }, [open, initialSection, searchTarget, target]); useEffect(() => { if (open) { @@ -473,7 +522,12 @@ export function SettingsDialog({ {sections.find((section) => section.id === s.sections[0])!.icon} - {s.label} + + {s.label} + {updateAvailable && s.sections.includes('version') ? ( + + ) : null} + {IS_WEB && } @@ -490,25 +544,52 @@ export function SettingsDialog({ aria-current={section.id === activeId ? 'page' : undefined} onClick={() => setActiveId(section.id)} > - {section.label} + + {section.label} + {updateAvailable && section.id === 'version' ? ( + + ) : null} ))} -
- - {active.id === 'anti-recall' ? ( - { - onClose(); - onOpenRecalls(kind, conv); - }} - /> - ) : ( - active.render(onOpenMaintenance, onCreateClone) - )} - +
+ + + + + {active.id === 'chat-messages' ? ( + <> +
+ } + /> +
+ + +
+ + {}} + onOpenRecalls={onOpenRecalls} + recallDetails={recallDetails} + onCloseRecalls={onCloseRecalls} + isCurrentSource={isCurrentSource} + /> + +
+ + + ) : ( + active.render(onOpenMaintenance, onCreateClone) + )} +
+
+
+
@@ -517,11 +598,12 @@ export function SettingsDialog({ } function ModelUsageSection(): ReactElement { - const personas = trpc.account.listAgentLabPersonas.useQuery(); + const active = useTabActive(); + const personas = trpc.account.listAgentLabPersonas.useQuery(undefined, { enabled: active }); const names = new Map((personas.data ?? []).map((persona) => [persona.id, persona.name])); return (
- + } /> names.get(id) ?? '已删除的克隆'} @@ -531,7 +613,14 @@ function ModelUsageSection(): ReactElement { ); } -function AppearanceSection({ page }: { page: 'theme' | 'messages' }): ReactElement { +function AppearanceSection({ + page, + heading = true, +}: { + page: 'theme' | 'messages'; + heading?: boolean; +}): ReactElement { + const active = useTabActive(); const preference = useThemeStore((state) => state.preference); const accent = useThemeStore((state) => state.accent); const background = useThemeStore((state) => state.background); @@ -544,6 +633,7 @@ function AppearanceSection({ page }: { page: 'theme' | 'messages' }): ReactEleme // 「纯文本消息渲染 Markdown」——不像主题那样存 localStorage,而是走 AppSettings // (config.json):渲染层要通过 App.tsx 的 TextMarkdownContext 广播给每条气泡。 const settings = trpc.bootstrap.getSettings.useQuery(undefined, { + enabled: active, refetchOnWindowFocus: false, staleTime: 0, refetchOnMount: 'always', @@ -551,12 +641,16 @@ function AppearanceSection({ page }: { page: 'theme' | 'messages' }): ReactEleme const setRenderTextMarkdown = trpc.bootstrap.setRenderTextMarkdown.useMutation(); const setShowAvatarPendant = trpc.bootstrap.setShowAvatarPendant.useMutation(); const setLinkPreview = trpc.bootstrap.setLinkPreview.useMutation(); - const setShowMsgDecoration = trpc.bootstrap.setShowMsgDecoration.useMutation(); + const setShowMessageBubble = trpc.bootstrap.setShowMessageBubble.useMutation(); + const setShowMessageFont = trpc.bootstrap.setShowMessageFont.useMutation(); const setShowMessageSeq = trpc.bootstrap.setShowMessageSeq.useMutation(); const setShowMessageTime = trpc.bootstrap.setShowMessageTime.useMutation(); const setShowMessageUnreadBadge = trpc.bootstrap.setShowMessageUnreadBadge.useMutation(); + const setShowContactUnreadCount = trpc.bootstrap.setShowContactUnreadCount.useMutation(); const setReadFromFirstUnread = trpc.bootstrap.setReadFromFirstUnread.useMutation(); const setJumpToLatestAfterSend = trpc.bootstrap.setJumpToLatestAfterSend.useMutation(); + const setShowScrollToBottomButton = trpc.bootstrap.setShowScrollToBottomButton.useMutation(); + const setSendWithQqFont = trpc.bootstrap.setSendWithQqFont.useMutation(); const setShowGroupAssistantMessageCount = trpc.bootstrap.setShowGroupAssistantMessageCount.useMutation(); const utils = trpc.useUtils(); @@ -564,8 +658,6 @@ function AppearanceSection({ page }: { page: 'theme' | 'messages' }): ReactEleme const [readingError, setReadingError] = useState(null); const [seqError, setSeqError] = useState(null); const [textMarkdown, setTextMarkdown] = useState(true); - const [pendant, setPendant] = useState(true); - const [msgDecoration, setMsgDecoration] = useState(true); const [linkCard, setLinkCard] = useState(true); const [linkShot, setLinkShot] = useState(false); @@ -574,16 +666,6 @@ function AppearanceSection({ page }: { page: 'theme' | 'messages' }): ReactEleme if (typeof enabled === 'boolean') setTextMarkdown(enabled); }, [settings.data?.renderTextMarkdown]); - useEffect(() => { - const enabled = settings.data?.showAvatarPendant; - if (typeof enabled === 'boolean') setPendant(enabled); - }, [settings.data?.showAvatarPendant]); - - useEffect(() => { - const enabled = settings.data?.showMsgDecoration; - if (typeof enabled === 'boolean') setMsgDecoration(enabled); - }, [settings.data?.showMsgDecoration]); - useEffect(() => { const cfg = settings.data?.linkPreview; if (typeof cfg?.enabled === 'boolean') setLinkCard(cfg.enabled); @@ -602,28 +684,6 @@ function AppearanceSection({ page }: { page: 'theme' | 'messages' }): ReactEleme await settings.refetch(); } - async function onSetPendant(next: boolean): Promise { - const prev = pendant; - setPendant(next); - try { - await setShowAvatarPendant.mutateAsync({ enabled: next }); - } catch { - setPendant(prev); - } - await settings.refetch(); - } - - async function onSetMsgDecoration(next: boolean): Promise { - const prev = msgDecoration; - setMsgDecoration(next); - try { - await setShowMsgDecoration.mutateAsync({ enabled: next }); - } catch { - setMsgDecoration(prev); - } - await settings.refetch(); - } - async function onSetLinkCard(next: boolean): Promise { const prev = linkCard; setLinkCard(next); @@ -648,10 +708,12 @@ function AppearanceSection({ page }: { page: 'theme' | 'messages' }): ReactEleme return (
- } - title={page === 'theme' ? '主题与背景' : '消息显示'} - /> + {heading && ( + } + title={page === 'theme' ? '主题与背景' : '聊天消息'} + /> + )} {page === 'theme' && ( <> @@ -698,29 +760,79 @@ function AppearanceSection({ page }: { page: 'theme' | 'messages' }): ReactEleme )} {page === 'messages' && ( <> +
+
+ 在消息图标上显示红点 + { + setBadgeError(null); + setShowMessageUnreadBadge.mutate( + { enabled }, + { + onSuccess: () => void utils.bootstrap.getSettings.invalidate(), + onError: (error) => setBadgeError(error.message), + }, + ); + }} + label="在消息图标上显示红点" + /> +
+ {badgeError ?

{badgeError}

: null} +
{[ + { + key: 'showContactUnreadCount' as const, + settingId: 'contact-unread-count', + label: '在联系人界面显示未读消息数', + fallback: false, + mutation: setShowContactUnreadCount, + }, + { + key: 'showGroupAssistantMessageCount' as const, + settingId: 'group-assistant-count', + label: '群助手显示总消息数', + fallback: false, + mutation: setShowGroupAssistantMessageCount, + }, { key: 'readFromFirstUnread' as const, + settingId: 'read-first-unread', label: '从第一条新消息开始阅读', fallback: false, mutation: setReadFromFirstUnread, }, { key: 'jumpToLatestAfterSend' as const, + settingId: 'jump-latest-after-send', label: '发送消息后定位到最新消息', fallback: true, mutation: setJumpToLatestAfterSend, }, { - key: 'showGroupAssistantMessageCount' as const, - label: '群助手显示总消息数', - fallback: false, - mutation: setShowGroupAssistantMessageCount, + key: 'showScrollToBottomButton' as const, + settingId: 'scroll-to-bottom-button', + label: '向下滚动时显示定位到底部按钮', + fallback: true, + mutation: setShowScrollToBottomButton, + }, + { + key: 'sendWithQqFont' as const, + settingId: 'send-with-qq-font', + label: '使用 QQ 账号设置的个性字体发送消息', + fallback: true, + mutation: setSendWithQqFont, }, - ].map(({ key, label, fallback, mutation }) => ( -
+ ].map(({ key, settingId, label, fallback, mutation }) => ( +
- {label} +
+ {label} + {key === 'readFromFirstUnread' ? ( + 可能提高阅读过的会话的加载速度 + ) : null} +
))} {readingError ?

{readingError}

: null} -
-
- 显示消息汇总红点 - { - setBadgeError(null); - setShowMessageUnreadBadge.mutate( - { enabled }, - { - onSuccess: () => void utils.bootstrap.getSettings.invalidate(), - onError: (error) => setBadgeError(error.message), - }, - ); - }} - label="显示消息汇总红点" - /> -
- {badgeError ?

{badgeError}

: null} -
-
-
-
- 纯文本消息渲染 Markdown - - 把普通文本消息里的 # 标题、**加粗**、代码块等语法渲染成富文本。这是 WeQ - 自己的功能——QQ 只把专门的 Markdown 消息当富文本,关掉后纯文本原样显示 (QQ 原生 - Markdown 消息不受影响)。 - -
- void onSetTextMarkdown(next)} - label="纯文本消息渲染 Markdown" - /> -
-
-
-
-
- 聊天头像挂件 - - 把 QQ 里正在用的挂件叠在聊天页头像的外圈。只作用于自己的头像——别人的挂件 - 要逐个联网查,想看请在资料卡里打开「个性主页」。 - -
- void onSetPendant(next)} - label="聊天头像挂件" - /> -
-
-
-
-
- 显示消息序号(seq) -
- { - setSeqError(null); - setShowMessageSeq.mutate( - { enabled }, - { - onSuccess: () => { - void settings.refetch(); + + + { + setSeqError(null); + setShowMessageSeq.mutate( + { enabled }, + { + onSuccess: () => { + void settings.refetch(); + }, + onError: (error) => setSeqError(error.message), }, - onError: (error) => setSeqError(error.message), - }, - ); - }} - label="显示消息序号(seq)" - /> -
+ ); + }} + label="显示消息序号(seq)" + /> + } + /> {seqError ?

{seqError}

: null} -
- 显示发送时间(精确到秒) - { - setSeqError(null); - setShowMessageTime.mutate( - { enabled }, - { - onSuccess: () => { - void settings.refetch(); + { + setSeqError(null); + setShowMessageTime.mutate( + { enabled }, + { + onSuccess: () => { + void settings.refetch(); + }, + onError: (error) => setSeqError(error.message), }, - onError: (error) => setSeqError(error.message), - }, - ); - }} - label="显示发送时间(精确到秒)" - /> -
-
-
-
-
- 消息装扮(气泡/字体/挂件) - - 渲染每条消息里的气泡皮肤、聊天字体与挂件(来自消息 DB 列 40801)。关掉后 - 统一使用全局装扮设置,不做逐条渲染。 - -
- void onSetMsgDecoration(next)} - label="消息装扮" + ); + }} + label="显示发送时间(精确到秒)" + /> + } + /> + {[ + { + key: 'showAvatarPendant' as const, + settingId: 'avatar-pendant', + label: '聊天头像挂件', + mutation: setShowAvatarPendant, + }, + { + key: 'showMessageBubble' as const, + settingId: 'message-bubble', + label: '消息气泡', + mutation: setShowMessageBubble, + }, + { + key: 'showMessageFont' as const, + settingId: 'message-font', + label: '消息字体', + mutation: setShowMessageFont, + }, + ].map(({ key, settingId, label, mutation }) => ( + { + setSeqError(null); + mutation.mutate( + { enabled }, + { + onSuccess: () => void utils.bootstrap.getSettings.invalidate(), + onError: (error) => setSeqError(error.message), + }, + ); + }} + label={label} + /> + } /> -
-
-
-
-
- 链接预览卡片 - + ))} + + 把普通文本消息里的 # 标题、**加粗**、代码块等语法渲染成富文本。这是 WeQ + 自己的功能——QQ 只把专门的 Markdown 消息当富文本,关掉后纯文本原样显示 (QQ 原生 + Markdown 消息不受影响)。 + + } + control={ + void onSetTextMarkdown(next)} + label="纯文本消息渲染 Markdown" + /> + } + /> + 一条消息只有一个链接时,抓取网页标题、简介和封面渲染成卡片。会按链接访问 对应网页(结果本地缓存),关掉后链接仍标蓝可点,但完全不出网。 - -
- void onSetLinkCard(next)} - label="链接预览卡片" - /> -
-
-
-
-
- 无封面时网页截图 - + + } + control={ + void onSetLinkCard(next)} + label="链接预览卡片" + /> + } + /> + 网页没提供封面图时,在一个隔离的后台窗口里把它打开并截取首屏当封面。这会 真正运行对方页面的脚本(沙盒内、不落盘、退出即清),默认关闭。 - -
- void onSetLinkShot(next)} - label="无封面时网页截图" - /> -
-
+ + } + control={ + void onSetLinkShot(next)} + label="无封面时网页截图" + /> + } + /> + )}
diff --git a/apps/desktop/src/renderer/src/components/ShimmerImage.tsx b/apps/desktop/src/renderer/src/components/ShimmerImage.tsx index a1312fe2b..f50dae69f 100644 --- a/apps/desktop/src/renderer/src/components/ShimmerImage.tsx +++ b/apps/desktop/src/renderer/src/components/ShimmerImage.tsx @@ -1,8 +1,7 @@ /** - * 图片带 shimmer 加载占位:图片字节未就绪时,在图片底下显示一块 shimmer 扫光 - * (复用全局 `.weq-shimmer-block`),加载完成后图片自然盖住它。 + * 图片未就绪时隐藏浏览器的 alt 文字,显示带 shimmer 的图形占位。 * - * 渲染为「shimmer 块 + 」两个同级元素,不改动调用方的布局结构——只需 + * 渲染为「占位图形 + 」两个同级元素,不改动调用方的布局结构——只需 * 保证父容器 `position: relative`(绝大多数缩略图 / 封面容器已具备,见各 CSS)。 * `src` 变化(含失败回退换源)时会重置 shimmer;`onLoad` / `onError` 与调用方 * 的处理器合并执行,失败时撤掉 shimmer 避免一直闪烁(调用方通常随后换源或 @@ -10,53 +9,142 @@ */ import { + useEffect, useLayoutEffect, - useRef, + useContext, useState, + useSyncExternalStore, type ImgHTMLAttributes, type ReactElement, } from 'react'; +import { Image as ImageIcon } from 'lucide-react'; import { useRenderDocument, resourceForRenderDocument } from '../lib/renderDocument'; import { useAvatarUrl } from '../hooks/useAvatarUrl'; +import { useTabActive } from './RetainedTabs'; +import { ChatImageCacheContext } from './ChatImageCacheProvider'; +import '../styles/chat-image-preview.css'; + +const noSubscription = () => () => {}; export function ShimmerImage({ className, alt = '', onLoad, onError, + suspendOffscreen = false, ...rest -}: ImgHTMLAttributes): ReactElement { +}: ImgHTMLAttributes & { suspendOffscreen?: boolean }): ReactElement { const [loaded, setLoaded] = useState(false); - const imgRef = useRef(null); + const [failed, setFailed] = useState(false); + const [image, setImage] = useState(null); const ownerDocument = useRenderDocument(); + const tabActive = useTabActive(); + const [visible, setVisible] = useState(false); + const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null); const avatarSrc = useAvatarUrl(rest.src, false); const src = avatarSrc ? resourceForRenderDocument(avatarSrc, ownerDocument) : rest.src; + const cache = useContext(ChatImageCacheContext); + const cacheKey = JSON.stringify([src, rest.srcSet]); + const retained = useSyncExternalStore( + suspendOffscreen && cache ? cache.subscribe : noSubscription, + () => suspendOffscreen && Boolean(cache?.has(cacheKey)), + ); + const budget = useSyncExternalStore( + suspendOffscreen && cache ? cache.subscribe : noSubscription, + () => cache?.stats().limit ?? 0, + ); + const active = tabActive && visible; + const enabled = !suspendOffscreen || active || retained; + + useEffect(() => { + if (suspendOffscreen && active) return cache?.activate(cacheKey); + }, [cache, cacheKey, suspendOffscreen, active]); + + // 调大预算时,当前已加载的图片也能加入缓存,不必等一次新的 load。 + useLayoutEffect(() => { + if (budget > 0 && suspendOffscreen && active && image?.complete && image.naturalWidth > 0) + cache?.remember(cacheKey, image); + }, [cache, cacheKey, budget, suspendOffscreen, active, image]); + + useEffect(() => { + if (!suspendOffscreen || !image) return; + if (!tabActive) { + setVisible(false); + return; + } + const doc = image.ownerDocument; + const Observer = doc.defaultView?.IntersectionObserver; + let intersecting = !Observer; + let disposed = false; + const update = () => { + if (!disposed) setVisible(intersecting && doc.visibilityState !== 'hidden'); + }; + // 更换加载节点时保留可见性,等新节点的观察结果;不能先置 false 造成反复挂载。 + if (!Observer || doc.visibilityState === 'hidden') update(); + const observer = Observer + ? new Observer( + (entries) => { + intersecting = entries.some((entry) => entry.isIntersecting); + update(); + }, + { rootMargin: '200px' }, + ) + : null; + observer?.observe(image); + doc.addEventListener('visibilitychange', update); + return () => { + disposed = true; + observer?.disconnect(); + doc.removeEventListener('visibilitychange', update); + }; + }, [suspendOffscreen, tabActive, ownerDocument, image]); // src 变化(首次挂载 / 失败回退换源)时重置;已缓存完成的直接显示,避免闪一下。 useLayoutEffect(() => { setLoaded(false); - if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) { + setFailed(false); + if (image?.complete && image.naturalWidth > 0) { setLoaded(true); } - }, [src]); + }, [src, image]); return ( <> - {!loaded ? : null} + {!enabled || !loaded ? ( + + {enabled && !failed ? : null} + + + ) : null} {alt} { + if (!enabled) return; + const image = event.currentTarget; + // 保留固有比例,移除 src 后未知尺寸的图片也不能压塌时间线。 + if (suspendOffscreen && image.naturalWidth && image.naturalHeight) + setDimensions({ width: image.naturalWidth, height: image.naturalHeight }); + if (suspendOffscreen) cache?.remember(cacheKey, image); setLoaded(true); onLoad?.(event); }} onError={(event) => { - setLoaded(true); + if (!enabled) return; + setLoaded(false); + setFailed(true); onError?.(event); }} {...rest} - src={src} + style={{ ...rest.style, opacity: enabled && loaded ? rest.style?.opacity : 0 }} + width={rest.width ?? (suspendOffscreen ? (dimensions?.width ?? 120) : undefined)} + height={rest.height ?? (suspendOffscreen ? (dimensions?.height ?? 96) : undefined)} + src={enabled ? src : undefined} + srcSet={enabled ? rest.srcSet : undefined} /> ); diff --git a/apps/desktop/src/renderer/src/components/TitleBarSearch.tsx b/apps/desktop/src/renderer/src/components/TitleBarSearch.tsx index 92b08211d..15cc4dc09 100644 --- a/apps/desktop/src/renderer/src/components/TitleBarSearch.tsx +++ b/apps/desktop/src/renderer/src/components/TitleBarSearch.tsx @@ -17,12 +17,14 @@ export function TitleBarSearch({ onSearchFocus, results, onReturn, + persistent = false, }: { query: string; onQueryChange: (query: string) => void; onSearchFocus?: () => void; results?: ReactNode; onReturn?: () => boolean; + persistent?: boolean; }) { const [pinned, setPinned] = useState(false); const [lookupOpen, setLookupOpen] = useState(null); @@ -34,7 +36,7 @@ export function TitleBarSearch({ const input = useRef(null); const panel = useRef(null); const returning = useRef(false); - const expanded = pinned || (hovered && !suppressHover); + const expanded = pinned || (!persistent && hovered && !suppressHover); const hasPanel = Boolean(results) || pinned; const layer = useOverlayLayer(expanded && hasPanel); @@ -94,7 +96,7 @@ export function TitleBarSearch({ return (
{ setHovered(true); setSuppressHover(false); @@ -153,10 +155,10 @@ export function TitleBarSearch({ ref={input} type="search" aria-label="搜索全部内容" - tabIndex={expanded ? 0 : -1} - disabled={!expanded} + tabIndex={persistent || expanded ? 0 : -1} + disabled={!persistent && !expanded} value={query} - placeholder="搜索全部内容" + placeholder={persistent ? '搜索内容、功能与设置' : '搜索全部内容'} onChange={(event) => onQueryChange(event.target.value)} onFocus={() => { setPinned(true); @@ -179,6 +181,13 @@ export function TitleBarSearch({ style={{ ...position, zIndex: layer }} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)} + onClick={(event) => { + if (!(event.target instanceof Element) || !event.target.closest('[role="option"]')) + return; + setPinned(false); + setHovered(false); + setSuppressHover(true); + }} > {results} {pinned && diff --git a/apps/desktop/src/renderer/src/components/Toast.tsx b/apps/desktop/src/renderer/src/components/Toast.tsx index 0284f2b01..035b47b42 100644 --- a/apps/desktop/src/renderer/src/components/Toast.tsx +++ b/apps/desktop/src/renderer/src/components/Toast.tsx @@ -50,6 +50,8 @@ export type ToastDurationKind = 'success' | 'failure' | 'long'; interface Toast { id: number; + /** 同一状态或操作的稳定标识,用于替换旧横幅及恢复后清理。 */ + key?: string; tone: ToastTone; /** 计时分类与视觉 tone 独立,long 沿用长期任务原有时长。 */ durationKind: ToastDurationKind; @@ -82,8 +84,9 @@ interface ToastStore { /** 正在播放退场动画的 toast id,动画结束后才真正移除 */ leaving: number[]; seq: number; - /** 推一条 toast,返回它的 id(之后可用 `update` 就地推进状态)。 */ + /** 推一条 toast;相同 key 原地替换,返回 id 供 `update` 推进状态。 */ push(input: { + key?: string; tone?: ToastTone; durationKind?: ToastDurationKind; loading?: boolean; @@ -106,6 +109,8 @@ interface ToastStore { update(id: number, patch: ToastPatch): void; /** 触发退场动画(自动过期 / 点击关闭) */ dismiss(id: number): void; + /** 对应问题已解决,不受失败通知停留时长或悬停暂停影响。 */ + resolve(key: string): void; /** 退场动画结束后真正移除 */ remove(id: number): void; } @@ -150,29 +155,44 @@ export const useToast = create((set, get) => ({ toasts: [], leaving: [], seq: 0, - push({ tone = 'info', durationKind, loading, title, message, detail, ttl, dismissible, action }) { - const id = get().seq + 1; + push({ + key, + tone = 'info', + durationKind, + loading, + title, + message, + detail, + ttl, + dismissible, + action, + }) { + const state = get(); + const existing = key === undefined ? undefined : state.toasts.find((item) => item.key === key); + const id = existing?.id ?? state.seq + 1; // 兼容旧调用点:只传一个内容时它就是标题;传了 title 时旧 message 变成详情 const actualTitle = title ?? message ?? ''; const actualDetail = detail ?? (title != null ? message : undefined); const kind = durationKind ?? defaultDurationKind(tone, ttl); + const toast: Toast = { + id, + key, + tone, + durationKind: kind, + loading, + title: actualTitle, + detail: actualDetail, + ttl: toastTtl({ durationKind: kind, loading, dismissible, ttl }, state.durations), + timerRevision: existing ? existing.timerRevision + 1 : 0, + dismissible, + action, + }; set({ - seq: id, - toasts: [ - ...get().toasts, - { - id, - tone, - durationKind: kind, - loading, - title: actualTitle, - detail: actualDetail, - ttl: toastTtl({ durationKind: kind, loading, dismissible, ttl }, get().durations), - timerRevision: 0, - dismissible, - action, - }, - ], + seq: Math.max(state.seq, id), + toasts: existing + ? state.toasts.map((item) => (item.id === id ? toast : item)) + : [...state.toasts, toast], + leaving: existing ? state.leaving.filter((item) => item !== id) : state.leaving, }); return id; }, @@ -203,6 +223,13 @@ export const useToast = create((set, get) => ({ if (get().leaving.includes(id)) return; set({ leaving: [...get().leaving, id] }); }, + resolve(key) { + const state = get(); + const ids = state.toasts + .filter((toast) => toast.key === key && !state.leaving.includes(toast.id)) + .map((toast) => toast.id); + if (ids.length) set({ leaving: [...state.leaving, ...ids] }); + }, remove(id) { set({ toasts: get().toasts.filter((t) => t.id !== id), diff --git a/apps/desktop/src/renderer/src/components/ToolSurface.tsx b/apps/desktop/src/renderer/src/components/ToolSurface.tsx new file mode 100644 index 000000000..36b7d3280 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/ToolSurface.tsx @@ -0,0 +1,69 @@ +import { useRef, type ReactNode } from 'react'; +import { DraftCloseContext, useDraftClose } from '../lib/draftClose'; +import { useModalBackground } from '../lib/modalBackground'; +import { OverlayLayer } from './OverlayLayer'; +import { WorkspacePage } from './WorkspacePages'; +import { useTabActive } from './RetainedTabs'; +import '../styles/tool-surface.css'; + +export type ToolPresentation = 'page' | 'modal'; + +/** 工具只切换容器,不注册第二套工作区;设置来源必须显式选择 modal。 */ +export function ToolSurface({ + title, + onClose, + presentation = 'page', + draft = null, + busy = false, + children, +}: { + title: string; + onClose: () => void; + presentation?: ToolPresentation; + draft?: unknown; + busy?: boolean; + children: ReactNode | ((requestClose: () => void) => ReactNode); +}) { + const closing = useDraftClose(onClose, draft, busy); + const content = ( + + {typeof children === 'function' ? children(closing.requestClose) : children} + + ); + return presentation === 'modal' ? ( + + {content} + + ) : ( + + {content} + + ); +} + +function ToolModal({ + title, + onClose, + children, +}: { + title: string; + onClose: () => void; + children: ReactNode; +}) { + const ref = useRef(null); + const active = useTabActive(); + useModalBackground(ref, active); + return ( + +
+ {children} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/WebConnectionStatus.tsx b/apps/desktop/src/renderer/src/components/WebConnectionStatus.tsx new file mode 100644 index 000000000..3b933aa7b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/WebConnectionStatus.tsx @@ -0,0 +1,35 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { WifiOff } from 'lucide-react'; +import { useWebConnection } from '../state/webConnection'; +import '../styles/web-connection.css'; + +export function WebConnectionStatus() { + const status = useWebConnection((state) => state.status); + const recovery = useWebConnection((state) => state.recovery); + const previousRecovery = useRef(recovery); + const queryClient = useQueryClient(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (recovery === previousRecovery.current) return; + previousRecovery.current = recovery; + // 只补查当前挂载的读取;订阅由 tRPC 恢复,不重放发送等 mutation。 + void queryClient.invalidateQueries({ refetchType: 'active' }); + }, [recovery, queryClient]); + + useEffect(() => { + setVisible(false); + if (status === 'connected') return; + const timer = window.setTimeout(() => setVisible(true), 1500); + return () => window.clearTimeout(timer); + }, [status]); + + if (!visible || status === 'connected') return null; + return ( +
+ + {status === 'connecting' ? '正在连接电脑…' : '实时连接已断开,正在重连…'} +
+ ); +} diff --git a/apps/desktop/src/renderer/src/components/WindowShakeMessage.tsx b/apps/desktop/src/renderer/src/components/WindowShakeMessage.tsx deleted file mode 100644 index 3664c8d56..000000000 --- a/apps/desktop/src/renderer/src/components/WindowShakeMessage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { FaceEmoji } from './FaceEmoji'; - -/** - * 私聊「窗口抖动」的本地乐观渲染。 - * - * 收端 QQ 会把 `serviceType=2` 的窗口抖动丢弃,所以这条消息只有发送方在**发出去那一下** - * 能看到。它不是灰条 —— 而是**自己发出的一条消息**,画面就是那枚会轻轻抖动的 - * 「戳一戳」超级表情(`resources/pokeemoji`),与收到的真实戳一戳同款,落在自己那一侧。 - * - * 由 `QqMessageContent` 在 `sticker-only` 容器里渲染(不带气泡底板,与超级表情一致)。 - * 样式见 index.css 的 `.weq-window-shake`。 - */ -export function WindowShakeMessage() { - return ( -
- -
- ); -} diff --git a/apps/desktop/src/renderer/src/components/WonderfulToolsDialog.tsx b/apps/desktop/src/renderer/src/components/WonderfulToolsDialog.tsx index 8436b9aa4..ae696f0b7 100644 --- a/apps/desktop/src/renderer/src/components/WonderfulToolsDialog.tsx +++ b/apps/desktop/src/renderer/src/components/WonderfulToolsDialog.tsx @@ -44,7 +44,7 @@ import { useQQReadActivity } from '../hooks/useQQAutoRefresh'; import { readWithRetry } from '../lib/autoRead'; import { QqAvatar } from './QqAvatar'; import { OverlayLayer } from './OverlayLayer'; -import { WorkspacePage } from './WorkspacePages'; +import { ToolSurface, type ToolPresentation } from './ToolSurface'; import { ReverseTool } from './ReverseTool'; import { CapturePanel } from './CapturePanel'; import { LoginAccountPanel } from './LoginAccountPanel'; @@ -85,7 +85,7 @@ export type ToolId = 'key-scan' | 'other-device-key' | 'capture' | 'reverse' | ' const TOOLS: { id: ToolId; label: string; desc: string }[] = [ { id: 'key-scan', label: '密钥扫描', desc: '只读内存扫描主密钥' }, { id: 'other-device-key', label: '其它设备密钥', desc: '获取账号其它设备的密钥' }, - { id: 'capture', label: 'ntqq 抓包', desc: '网卡收发包 · 解密展开' }, + { id: 'capture', label: 'QQNT 抓包', desc: '网卡收发包 · 解密展开' }, { id: 'reverse', label: 'Protobuf/JCE 逆向', desc: 'hex/base64 → 简洁 JSON' }, { id: 'login-accounts', label: 'QQ 登录账号管理', desc: '快速登录 · 删除账号 · 更换设备标识' }, ]; @@ -291,12 +291,14 @@ export function WonderfulToolsDialog({ onClose, initialTool = 'capture', tools = ['capture', 'reverse'], + presentation = 'page', }: { open: boolean; onClose: () => void; /** 打开时落在哪一个工具上(损坏弹窗要横着跳进「数据库修复」)。 */ initialTool?: ToolId; tools?: readonly ToolId[]; + presentation?: ToolPresentation; }): ReactElement | null { const visibleTools = TOOLS.filter((tool) => tools.includes(tool.id)); const selectedTool = visibleTools.some((tool) => tool.id === initialTool) @@ -317,8 +319,8 @@ export function WonderfulToolsDialog({ const [accounts, setAccounts] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - /** 全局「自动读取 QQ 内存(完整功能)」——关闭即完全离线模式。 */ - const [autoAttachQq, setAutoAttachQq] = useState(true); + /** 只信宿主当前来源的有效运行态,不能用自动偏好推测能力。 */ + const [fullModeEnabled, setFullModeEnabled] = useState(false); const [scanningUin, setScanningUin] = useState(null); /** 正在扫描 / 已出结果的账号 —— 存在时弹出结果模态窗口。 */ const [scanTarget, setScanTarget] = useState(null); @@ -327,6 +329,7 @@ export function WonderfulToolsDialog({ /** 扫描的两阶段进度;还没有收到任何进度事件时为 null(显示不确定进度)。 */ const [scanProgress, setScanProgress] = useState(null); const scanSubRef = useRef<{ unsubscribe: () => void } | null>(null); + const scanRun = useRef(0); // —— 其它设备密钥 —— const [otherDbPath, setOtherDbPath] = useState(null); @@ -351,25 +354,26 @@ export function WonderfulToolsDialog({ /** 当前在线的 QQ 实例数(密钥扫描用:只读内存,离线模式下仍可用)。 */ const onlineCount = accounts.filter((a) => a.pid !== null).length; /** 其它设备密钥需要「已读取内存」的在线实例;完全离线模式下视为 0。 */ - const otherKeyOnline = autoAttachQq ? onlineCount : 0; + const otherKeyOnline = fullModeEnabled ? onlineCount : 0; useEffect(() => { - if (!open) return undefined; + if (!open || !activity.enabled) return undefined; let alive = true; - void client.bootstrap.getSettings + void client.bootstrap.getFullModeState .query() .then((s) => { - if (alive) setAutoAttachQq(s.autoAttachQq); + if (alive) setFullModeEnabled(s.allowed); }) .catch(() => { - /* 读不到就按默认开启处理 */ + if (alive) setFullModeEnabled(false); }); return () => { alive = false; }; - }, [open]); + }, [open, activity.enabled]); const closeResult = useCallback(() => { + scanRun.current += 1; scanSubRef.current?.unsubscribe(); scanSubRef.current = null; setScanTarget(null); @@ -379,6 +383,15 @@ export function WonderfulToolsDialog({ setScanProgress(null); }, []); + useEffect(() => { + if (!activity.enabled) closeResult(); + return () => { + scanRun.current += 1; + scanSubRef.current?.unsubscribe(); + scanSubRef.current = null; + }; + }, [activity.enabled, closeResult]); + const loadAccounts = useCallback(async () => { if (!readEnabled.current) return; accountsController.current?.abort(); @@ -433,6 +446,7 @@ export function WonderfulToolsDialog({ }, [activity.enabled, activity.revision, activeTool, scanningUin, fetchingKey, loadAccounts]); async function scanAccount(acc: AccountRow): Promise { + const run = ++scanRun.current; setScanTarget(acc); setScanningUin(acc.uin); setResult(null); @@ -442,7 +456,7 @@ export function WonderfulToolsDialog({ scanSubRef.current?.unsubscribe(); scanSubRef.current = client.wonderfulTools.onKeyScanProgress.subscribe(undefined, { onData: (p) => { - if (p.uin !== acc.uin) return; + if (run !== scanRun.current || p.uin !== acc.uin) return; setScanProgress({ phase: p.phase, percent: p.percent, message: p.message }); }, onError: () => { @@ -451,8 +465,10 @@ export function WonderfulToolsDialog({ }); try { const r = await client.wonderfulTools.scanKey.query({ uin: acc.uin }); + if (run !== scanRun.current) return; setResult({ ...r, uin: acc.uin, name: acc.userName || acc.uin, pid: acc.pid }); } catch (e) { + if (run !== scanRun.current) return; setResult({ success: false, error: errMsg(e), @@ -461,9 +477,11 @@ export function WonderfulToolsDialog({ pid: acc.pid, }); } finally { - setScanningUin(null); - scanSubRef.current?.unsubscribe(); - scanSubRef.current = null; + if (run === scanRun.current) { + setScanningUin(null); + scanSubRef.current?.unsubscribe(); + scanSubRef.current = null; + } } } @@ -534,454 +552,460 @@ export function WonderfulToolsDialog({ if (!open) return null; return ( - item.id === activeTool)?.label ?? '诊断工具'} onClose={onClose} + presentation={presentation} > -
- - -
- {/* 左栏:工具列表 */} - + + {/* 右栏:工具界面 */} +
+ {activeTool === 'key-scan' ? ( + <> +
+
+ +

密钥扫描

+ {!loading && accounts.length > 0 ? ( + + {onlineCount} / {accounts.length} 在线 + + ) : null} +
+
+ +
+ {loading ? ( +
+ + 正在读取账号与在线状态… +
+ ) : error ? ( +
+ {error} + +
+ ) : accounts.length === 0 ? ( +
+ 未在 login.db 中发现历史账号,请先在 QQ / WeQ 登录过账号。 +
+ ) : ( +
+ {accounts.map((acc) => { + const online = acc.pid !== null; + const busy = scanningUin === acc.uin; + return ( + + ); + })} +
+ )}
- ) : headerError ? ( -
- {headerError} + + ) : null} + + {activeTool === 'other-device-key' ? ( + <> +
+
+ +

其它设备密钥

+ 0 ? ' is-online' : ''}`} + > + {fullModeEnabled + ? otherKeyOnline > 0 + ? `${otherKeyOnline} 个在线实例可用` + : '无在线实例' + : '完整功能未启用'} + +
+
+ {/* void loadAccounts()}*/} + {/* disabled={loading}*/} + {/*>*/} + {/* */} + {/* 刷新在线*/} + {/**/} +
+
+ +
+ {/* 选择数据库 */} +
+ + + {otherDbPath ?? '请选择其它设备导出的 nt_msg.db'} + +
+ + {/* 数据库头部 hexdump(高亮 db_salt) */} + {headerLoading ? ( +
+ + 正在读取数据库头部… +
+ ) : headerError ? ( +
+ {headerError} +
+ ) : headerHex && saltInfo ? ( +
+ +
+ key_meta + + {saltInfo.dbSalt.length > 64 + ? `${saltInfo.dbSalt.slice(0, 64)}…` + : saltInfo.dbSalt || '(空)'} + + + {saltInfo.saltValid ? '有效' : '无效'} + +
+
+ ) : ( +
+ + 选择数据库后在此显示头部 hexdump +
+ )} + + {/* 获取密钥 */} +
+ + {otherKeyOnline === 0 && !fetchingKey ? ( + + {fullModeEnabled + ? '无在线实例,按钮保持灰色:请先登录 QQ 并保持在线' + : '本次运行未启用完整功能,无法向 QQ 请求密钥'} + + ) : null} +
+ + {/* hexdump 下方:加载动画 → 密钥 / 失败原因 */} + {fetchingKey ? ( +
+ + 正在通过在线 QQ 请求密钥(OIDB 0xcde_2)… +
+ ) : otherResult ? ( + otherResult.success && otherResult.key ? ( +
+ {/*获取成功*/} +
+ {otherResult.key} + +
+ {otherResult.pid ? ( + + 由在线实例 PID {otherResult.pid} 返回 + + ) : null} +
+ ) : ( +
+ 获取失败 +

{otherResult.error ?? '未知错误'}

+
+ ) + ) : null}
- ) : headerHex && saltInfo ? ( -
- + ) : null} + + {activeTool === 'capture' ? ( + <> +
+
+ +

QQNT 抓包

+
+
+ {/* 抓包面板自带分栏与滚动,所以正文区全出血(去掉内边距、不自己滚)。 */} +
+ ({ + uin: acc.uin, + name: acc.userName, + avatarUrl: acc.avatarUrl, + pid: acc.pid, + }))} /> -
- key_meta - - {saltInfo.dbSalt.length > 64 - ? `${saltInfo.dbSalt.slice(0, 64)}…` - : saltInfo.dbSalt || '(空)'} - - - {saltInfo.saltValid ? '有效' : '无效'} - +
+ + ) : null} + + {activeTool === 'login-accounts' ? ( + <> +
+
+ +

QQ 登录账号管理

+
+
+
- ) : ( -
- - 选择数据库后在此显示头部 hexdump + + ) : null} + + {activeTool === 'reverse' ? ( + <> +
+
+ +

Protobuf / JCE 逆向

+
+
+
+
- )} + + ) : null} +
+
+
- {/* 获取密钥 */} -
- - {otherKeyOnline === 0 && !fetchingKey ? ( - - {autoAttachQq - ? '无在线实例,按钮保持灰色:请先登录 QQ 并保持在线' - : '已开启完全离线模式(自动读取 QQ 内存 已关闭),无法向 QQ 请求密钥'} - - ) : null} -
- - {/* hexdump 下方:加载动画 → 密钥 / 失败原因 */} - {fetchingKey ? ( -
- - 正在通过在线 QQ 请求密钥(OIDB 0xcde_2)… + {/* 扫描结果模态窗口:头像 / 昵称 / QQ 号 / PID + 密钥(复制) */} + {scanTarget ? ( + +
e.stopPropagation()} + > + +
+ +
+ + {scanTarget.userName || scanTarget.uin} + + QQ 号 {scanTarget.uin} + {scanTarget.pid ? ( + PID {scanTarget.pid} + ) : null} + +
+ {scanningUin ? ( +
+
+ + + {scanProgress?.message ?? '正在扫描进程内存并验证密钥…'} + + {scanProgress ? ( + {scanProgress.percent}% + ) : null} +
+
+
+
+ {scanProgress?.phase === 'strong' ? ( + + 锚点附近没找到可验证的密钥,正在回退扫描整个内存(较慢) + + ) : null}
- ) : otherResult ? ( - otherResult.success && otherResult.key ? ( -
- {/*获取成功*/} + ) : result ? ( + result.success && result.key ? ( + <> + 扫描成功
- {otherResult.key} + {result.key}
- {otherResult.pid ? ( - - 由在线实例 PID {otherResult.pid} 返回 - + {result.keyContextHex ? ( + ) : null} -
+ ) : ( -
- 获取失败 -

{otherResult.error ?? '未知错误'}

+
+ 扫描未成功 +

{result.error ?? '未知错误'}

) ) : null}
- - ) : null} - - {activeTool === 'capture' ? ( - <> -
-
- -

ntqq 抓包

-
-
- {/* 抓包面板自带分栏与滚动,所以正文区全出血(去掉内边距、不自己滚)。 */} -
- ({ - uin: acc.uin, - name: acc.userName, - avatarUrl: acc.avatarUrl, - pid: acc.pid, - }))} - /> -
- - ) : null} - - {activeTool === 'login-accounts' ? ( - <> -
-
- -

QQ 登录账号管理

-
-
-
- -
- - ) : null} - - {activeTool === 'reverse' ? ( - <> -
-
- -

Protobuf / JCE 逆向

-
-
-
- -
- - ) : null} -
-
-
- - {/* 扫描结果模态窗口:头像 / 昵称 / QQ 号 / PID + 密钥(复制) */} - {scanTarget ? ( - -
e.stopPropagation()} - > - -
- -
- - {scanTarget.userName || scanTarget.uin} - - QQ 号 {scanTarget.uin} - {scanTarget.pid ? ( - PID {scanTarget.pid} - ) : null} - -
- {scanningUin ? ( -
-
- - - {scanProgress?.message ?? '正在扫描进程内存并验证密钥…'} - - {scanProgress ? ( - {scanProgress.percent}% - ) : null} -
-
-
-
- {scanProgress?.phase === 'strong' ? ( - - 锚点附近没找到可验证的密钥,正在回退扫描整个内存(较慢) - - ) : null} -
- ) : result ? ( - result.success && result.key ? ( - <> - 扫描成功 -
- {result.key} - -
- {result.keyContextHex ? ( - - ) : null} - - ) : ( -
- 扫描未成功 -

{result.error ?? '未知错误'}

-
- ) - ) : null} -
-
-
- ) : null} - + + + ) : null} + + )} + ); } diff --git a/apps/desktop/src/renderer/src/components/WorkspaceMenuButton.tsx b/apps/desktop/src/renderer/src/components/WorkspaceMenuButton.tsx new file mode 100644 index 000000000..dd57f5361 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/WorkspaceMenuButton.tsx @@ -0,0 +1,54 @@ +import { Menu, Settings } from 'lucide-react'; +import { useWorkspaceMenu } from '../state/workspaceMenu'; +import { useUpdateStore } from '../state/update'; +import { WorkspaceStatusDot } from './WorkspaceStatusDot'; +import '../styles/rail-navigation.css'; +import '../styles/workspace-menu.css'; + +export function WorkspaceMenuButton() { + const anchor = useWorkspaceMenu((state) => state.anchor); + const toggle = useWorkspaceMenu((state) => state.toggle); + const running = useWorkspaceMenu((state) => state.running); + const failed = useWorkspaceMenu((state) => state.failed); + const finished = useWorkspaceMenu((state) => state.finished); + const assistant = useWorkspaceMenu((state) => state.assistant); + const assistantUnread = useWorkspaceMenu((state) => state.assistantUnread); + const update = useUpdateStore((state) => state.available); + const label = `菜单${running ? `,${running} 项任务进行中` : ''}${finished ? `,${finished} 项任务已结束` : ''}${failed ? `,${failed} 项任务失败` : ''}${assistant === 'running' ? ',助手回答中' : assistant === 'finished' ? ',助手回答已结束' : ''}${update ? ',有新版本' : ''}`; + return ( + + ); +} + +export function WorkspaceSettingsButton({ onClick }: { onClick: () => void }) { + const update = useUpdateStore((state) => state.available); + return ( + + ); +} diff --git a/apps/desktop/src/renderer/src/components/WorkspacePages.tsx b/apps/desktop/src/renderer/src/components/WorkspacePages.tsx index bbc9d708c..f5c2adf1a 100644 --- a/apps/desktop/src/renderer/src/components/WorkspacePages.tsx +++ b/apps/desktop/src/renderer/src/components/WorkspacePages.tsx @@ -137,7 +137,7 @@ export function WorkspaceContent({ children }: { children: ReactNode }) {