diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26b18151..e475426f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,3 +130,39 @@ jobs: mv apps/desktop/package.dev.json apps/desktop/package.json fi rm -f apps/desktop/package.release.json + + # 浏览器版打包冒烟:构建 → 两个守卫 → 启动真实产物走一遍鉴权流程。 + # 守卫防的是同一类回归:Electron-only 的代码混进 web 构建后不会报错, + # 只在运行时炸成一句看不懂的 "does not provide an export named 'app'"。 + build-web: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # 鉴权闸 + 端到端路由测试。不需要构建产物,先跑、失败得早。 + - name: Test auth gate + run: pnpm --filter @weq/web test + + # build 内含 check:check-electron-free(走真实 import 图) + # + check-bundle(扫前端产物里残留的自定义协议 URL)。 + - name: Build + guards + run: pnpm --filter @weq/web build + + # 启动打包好的 server.mjs 走一遍完整流程 —— 覆盖 esbuild 的 CJS 互操作 + # 和原生模块解析,这些只有真跑起来才会暴露。 + - name: Smoke-test packaged server + run: pnpm --filter @weq/web test:dist diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 530382bd..50c9f20f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,11 +221,64 @@ jobs: echo "== release/ tree ==" ls -laR apps/desktop/release 2>/dev/null || echo "(no release/ dir)" + # ============================ Web(浏览器版)============================ + # 平台无关:一个 tar.gz 同时含 win32-x64 / linux-x64 / linux-arm64 三份 + # native/,运行时按 process.platform/arch 自动选,所以只构建一次。 + # 不内置 Node(要求机器上有 Node ≥22),产物约 24 MB。 + # + # 依赖 windows job:Release 由 electron-builder --publish 创建,这里只往 + # 已存在的 Release 上传,不自己建。 + web: + needs: [windows] + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # 版本号会被 esbuild 烘焙进 server.mjs(设置页显示 + 压缩包命名)。 + - name: Sync version from tag + run: node scripts/set-version.mjs ${{ github.ref_name }} + + # 含 check 守卫:确认没有 Electron 代码泄漏进 web 构建。 + - name: Build + guards + run: pnpm --filter @weq/web build + + # 发布前先启动打包产物验一遍,别把跑不起来的包传上去。 + - name: Smoke-test packaged server + run: pnpm --filter @weq/web test:dist + + - name: Pack tarball + run: pnpm --filter @weq/web pack:release ${{ github.ref_name }} + + - name: Upload to release + run: gh release upload "${{ github.ref_name }}" apps/web/release/*.tar.gz --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Dump build output on failure + if: failure() + run: | + echo "== apps/web/dist ==" + ls -laR apps/web/dist 2>/dev/null | head -50 || echo "(no dist/)" + # ======================= Release 说明(中文)======================= - # 等三个平台都发完(Release 已存在),用「中文说明头 + GitHub 自动 + # 等四个产物都发完(Release 已存在),用「中文说明头 + GitHub 自动 # 生成的 commit/PR 变更列表」覆写 Release 正文,并标注 aarch64 未实测。 notes: - needs: [windows, linux-x64, linux-arm64] + needs: [windows, linux-x64, linux-arm64, web] runs-on: ubuntu-latest steps: - name: 生成并写入中文 Release 说明 @@ -254,6 +307,9 @@ jobs: - **Windows**: \`weQ-${TAG#v}-setup.exe\` - **Linux x64**: \`weQ-${TAG#v}-linux-x64.AppImage\` / \`.tar.gz\` - **Linux arm64(未实测)**: \`weQ-${TAG#v}-linux-arm64.AppImage\` / \`.tar.gz\` + - **浏览器版**: \`weq-web-${TAG#v}.tar.gz\` —— 三平台通用,需自备 Node ≥22。 + 解压后 \`npm install --omit=dev && node server.mjs\`, + 详见[使用说明](https://github.com/${REPO}/blob/main/apps/web/README.md) ### 变更 ${CHANGES} diff --git a/.gitignore b/.gitignore index 2ea96723..807d89cd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ out/ build/ *.tsbuildinfo +# weq web runtime dirs (exports / logs, created next to the server) +weq-exports/ +weq-data/ + # editor / OS .DS_Store diff --git a/README.md b/README.md index 144a5f17..0ef3a7da 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,21 @@ 2. 按照引导操作获取数据库密钥 (**无需提前打开QQ**) 3. 打开对应账号即可开始使用 +### 浏览器版 + +除桌面版外还提供 **WeQ Web** —— 同一套界面与功能,跑在浏览器里。适合无桌面环境的机器 +(NAS / 服务器 / WSL),或想从别的设备访问。 + +下载 `weq-web-<版本>.tar.gz`(三平台通用,需自备 Node ≥ 22),解压后: + +```bash +npm install --omit=dev +node server.mjs +``` + +终端会打印地址和访问令牌,浏览器打开即可。默认只监听本机; +**对外暴露前请先读 [apps/web/README.md](./apps/web/README.md)**。 + #### 开发者指南 > diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 65612e5b..5f920d23 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -58,6 +58,8 @@ export default defineConfig({ alias: { '@renderer': resolve(__dirname, 'src/renderer/src'), '@resources': resolve(__dirname, '../../resources'), + // Per-target tRPC transport; the web app aliases this to transport.web. + '@transport': resolve(__dirname, 'src/renderer/src/trpc/transport.electron.ts'), }, }, build: { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a50e52e4..ee8d596a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "main": "./out/main/index.js", + "exports": { + "./main/*": "./src/main/*.ts" + }, "scripts": { "dev": "electron-vite dev", "build": "electron-vite build && electron-builder", diff --git a/apps/desktop/src/main/avatar_protocol.ts b/apps/desktop/src/main/avatar_protocol.ts index 7e83ecb3..c0f3272b 100644 --- a/apps/desktop/src/main/avatar_protocol.ts +++ b/apps/desktop/src/main/avatar_protocol.ts @@ -16,7 +16,6 @@ * `ready`; `registerAvatarProtocol()` MUST run after. */ -import { protocol } from 'electron'; import { getAppContext } from './context/app_context'; export const AVATAR_SCHEME = 'weq-avatar'; @@ -36,32 +35,34 @@ export const AVATAR_PRIVILEGED_SCHEME = { }, } as const; -export function registerAvatarProtocol(): void { - protocol.handle(AVATAR_SCHEME, async (request) => { - const url = new URL(request.url); - const src = url.searchParams.get('src'); - if (!src) { - return new Response('missing src', { status: 400 }); - } +/** + * Serve one `weq-avatar://` request. Pure `Request`→`Response`, so the web app + * can mount it on a plain HTTP route (see `apps/web`) without Electron. + */ +export async function handleAvatarRequest(request: Request): Promise { + const url = new URL(request.url); + const src = url.searchParams.get('src'); + if (!src) { + return new Response('missing src', { status: 400 }); + } - const ctx = getAppContext(); - if (!ctx.bootstrap) { - return new Response('native unavailable', { status: 503 }); - } + const ctx = getAppContext(); + if (!ctx.bootstrap) { + return new Response('native unavailable', { status: 503 }); + } - try { - const blob = await ctx.bootstrap.avatarCache.get(src); - return new Response(new Uint8Array(blob.data), { - status: 200, - headers: { - 'Content-Type': blob.contentType, - // Let the renderer / Chromium memory-cache it too; the on-disk cache - // is authoritative, this just avoids re-asking the protocol. - 'Cache-Control': 'public, max-age=86400', - }, - }); - } catch { - return new Response('avatar fetch failed', { status: 502 }); - } - }); + try { + const blob = await ctx.bootstrap.avatarCache.get(src); + return new Response(new Uint8Array(blob.data), { + status: 200, + headers: { + 'Content-Type': blob.contentType, + // Let the renderer / Chromium memory-cache it too; the on-disk cache + // is authoritative, this just avoids re-asking the protocol. + 'Cache-Control': 'public, max-age=86400', + }, + }); + } catch { + return new Response('avatar fetch failed', { status: 502 }); + } } diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index 26a0c31c..02aef54e 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -31,7 +31,7 @@ import { aiToolSpecs, runAiTool } from '../mcp/openai_tools'; import { getExternalMcpHub, disposeExternalMcp } from '../mcp/external'; import { sampleHitokoto } from '../hitokoto'; import { pkexecStubHooks } from '../stub_elevation'; -import { getQqProtocolExe } from './qq_protocol'; +import { getQqProtocolExe } from './qq_protocol_cache'; import { createPkexecInjectHook } from '../inject_elevation'; import { accountConfigId, diff --git a/apps/desktop/src/main/context/qq_protocol.ts b/apps/desktop/src/main/context/qq_protocol.ts index 1c664235..375f3c55 100644 --- a/apps/desktop/src/main/context/qq_protocol.ts +++ b/apps/desktop/src/main/context/qq_protocol.ts @@ -1,32 +1,24 @@ /** - * Resolve which exe the OS associates with QQ's `tencent://` URL scheme, so - * the win32 platform can anchor every install path (QQ.exe / wrapper.node / - * version) on it instead of the `Uninstall\QQ` registry key — which is missing - * or relocated for portable installs, non-standard layouts, and machines whose - * registry has been cleaned. + * Probe which exe the OS associates with QQ's `tencent://` URL scheme. * - * The handler is QQNT's `timwp.exe`, sitting in the same `resources/app` dir as - * `wrapper.node`. We prefer `tencent://`, then `mqqapi://` (both point at the - * same handler in practice; the second is a fallback for installs that only - * registered one). Anything else — no association, throw — leaves the cached - * value null and the platform silently falls back to the registry probe. + * We prefer `tencent://`, then `mqqapi://` (both point at the same handler in + * practice; the second is a fallback for installs that only registered one). + * Anything else — no association, throw — leaves the cached value null and the + * platform silently falls back to the registry probe. * * Win32-only: linux QQ doesn't register these schemes, so the caller skips the - * probe there entirely and the getter stays null. + * probe there entirely and the cache stays null. + * + * Electron-only (needs `app.getApplicationInfoForProtocol`). The cache itself + * lives in `qq_protocol_cache.ts` so non-Electron hosts can read it. */ import { app } from 'electron'; import { getLogger } from '@weq/service'; +import { setQqProtocolExe } from './qq_protocol_cache'; const SCHEMES = ['tencent://', 'mqqapi://'] as const; -let cachedExe: string | null = null; - -/** The resolved protocol-handler exe path, or null until/unless the probe finds one. */ -export function getQqProtocolExe(): string | null { - return cachedExe; -} - /** * Probe the OS protocol association once and cache the handler exe path. Safe * to call before any path lookup; resolves (never rejects) so a missing @@ -38,7 +30,7 @@ export async function probeQqProtocolHandler(): Promise { try { const info = await app.getApplicationInfoForProtocol(scheme); if (info.path) { - cachedExe = info.path; + setQqProtocolExe(info.path); logger.info('resolved QQ protocol handler', { event: 'qq-protocol-resolved', scheme, diff --git a/apps/desktop/src/main/context/qq_protocol_cache.ts b/apps/desktop/src/main/context/qq_protocol_cache.ts new file mode 100644 index 00000000..a499f3dc --- /dev/null +++ b/apps/desktop/src/main/context/qq_protocol_cache.ts @@ -0,0 +1,23 @@ +/** + * Cache for the exe the OS associates with QQ's `tencent://` URL scheme, so the + * win32 platform can anchor every install path (QQ.exe / wrapper.node / + * version) on it instead of the `Uninstall\QQ` registry key — which is missing + * or relocated for portable installs, non-standard layouts, and machines whose + * registry has been cleaned. + * + * Only the cache lives here. The probe that fills it needs Electron's + * `app.getApplicationInfoForProtocol` and therefore sits in `qq_protocol.ts`, + * which only the desktop shell imports — this module stays Electron-free so + * `app_context` (and through it the web app) can depend on it. + */ + +let cachedExe: string | null = null; + +/** The resolved protocol-handler exe path, or null until/unless a probe finds one. */ +export function getQqProtocolExe(): string | null { + return cachedExe; +} + +export function setQqProtocolExe(path: string | null): void { + cachedExe = path; +} diff --git a/apps/desktop/src/main/file_response.ts b/apps/desktop/src/main/file_response.ts new file mode 100644 index 00000000..49c71f43 --- /dev/null +++ b/apps/desktop/src/main/file_response.ts @@ -0,0 +1,111 @@ +/** + * `file://` → `Response`, without Electron's `net.fetch`. + * + * The protocol handlers used `net.fetch(pathToFileURL(p))` to stream a file off + * disk with `Range` support. Node's global `fetch` refuses `file://`, so the web + * app needs its own implementation — and since it must behave identically in + * both shells, Electron uses this one too. + * + * Streams via `createReadStream` (never buffers whole files: videos are large + * and `