Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 58 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 说明
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**。

#### 开发者指南

>
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 28 additions & 27 deletions apps/desktop/src/main/avatar_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Response> {
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 });
}
}
2 changes: 1 addition & 1 deletion apps/desktop/src/main/context/app_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 11 additions & 19 deletions apps/desktop/src/main/context/qq_protocol.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -38,7 +30,7 @@ export async function probeQqProtocolHandler(): Promise<void> {
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,
Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/main/context/qq_protocol_cache.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading