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
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ jobs:
- name: Build + guards
run: pnpm --filter @weq/web build

# 把 ws / resvg 预装进 dist/node_modules 一起发布,用户解压即可运行,
# 不需要联网 npm install。resvg 按平台选 binding,所以三平台的都装上。
- name: Install runtime deps into dist/
run: pnpm --filter @weq/web deps

# 发布前先启动打包产物验一遍,别把跑不起来的包传上去。
- name: Smoke-test packaged server
run: pnpm --filter @weq/web test:dist
Expand Down Expand Up @@ -308,7 +313,8 @@ jobs:
- **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\`,
解压后 Windows 双击 \`start.bat\`、Linux 跑 \`./start.sh\`(依赖已预装)。
Linux 建议用 root 运行,
详见[使用说明](https://github.com/${REPO}/blob/main/apps/web/README.md)

### 变更
Expand Down
26 changes: 17 additions & 9 deletions apps/desktop/src/main/context/app_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,17 @@ import { ensureDefaultTweets, tweetsStorePath } from '../weq_assistant/tweets';
import { aiToolSpecs, runAiTool } from '../mcp/openai_tools';
import { getExternalMcpHub, disposeExternalMcp } from '../mcp/external';
import { sampleHitokoto } from '../hitokoto';
import { pkexecStubHooks } from '../stub_elevation';
import { linuxStubHooks } from '../stub_elevation';
import { getQqProtocolExe } from './qq_protocol_cache';
import { createPkexecInjectHook } from '../inject_elevation';
import { createLinuxInjectHook } from '../inject_elevation';
import {
accountConfigId,
UserConfigService,
Win32DetectService,
Win32KeyService,
GlobalConfigService,
AvatarCacheService,
LinkPreviewService,
AgentLabConfigService,
VoiceTranscribeService,
TtsService,
Expand Down Expand Up @@ -339,6 +340,8 @@ export interface BootstrapServices {
userConfig: UserConfigService;
globalConfig: GlobalConfigService;
avatarCache: AvatarCacheService;
/** 聊天里裸链接 → og 卡片(抓取带 SSRF 闸门,见 service 侧)。Account-independent。 */
linkPreview: LinkPreviewService;
agentLabConfig: AgentLabConfigService;
/** Voice-transcription model management (download/select). Account-independent. */
voiceTranscribe: VoiceTranscribeService;
Expand Down Expand Up @@ -578,24 +581,29 @@ export function initAppContext(): AppContext {
}

// Linux drops a ninebird entry stub into QQ's root-owned resources/app, so
// it needs a pkexec-elevated writer. Windows uses the fs default (undefined).
const stubHooks = process.platform === 'linux' ? pkexecStubHooks : undefined;
// it needs an elevated writer unless the host is already root. Windows uses
// the fs default (undefined).
const stubHooks = process.platform === 'linux' ? linuxStubHooks : undefined;

// Injecting the hook into a running QQ needs root (ptrace) on linux, so it
// goes through a pkexec child + a wait-for-packet step; other platforms
// inject in-process. One shared instance so its per-pid idempotency spans the
// bootstrap router and every account monitor.
// Injecting the hook into a running QQ needs root (ptrace) on linux, and the
// hook must then observe a real post-login packet before it can send; both
// halves live in the linux hook. Other platforms inject in-process with no
// wait. One shared instance so its per-pid idempotency spans the bootstrap
// router and every account monitor.
const injectHook: InjectHook =
process.platform === 'linux'
? createPkexecInjectHook(platform.native.ntHelper, userConfig)
? createLinuxInjectHook(platform.native.ntHelper, userConfig)
: createDirectInjectHook(platform.native.ntHelper);

const linkPreview = new LinkPreviewService(userConfig);

const bootstrap: BootstrapServices = {
detect: new Win32DetectService(platform, stubHooks),
keys: new Win32KeyService(platform, stubHooks),
userConfig,
globalConfig: new GlobalConfigService(platform, userConfig),
avatarCache: new AvatarCacheService(platform, userConfig),
linkPreview,
agentLabConfig: new AgentLabConfigService(userConfig),
voiceTranscribe: new VoiceTranscribeService(platform),
tts: new TtsService(),
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
} from '@weq/service';
import { electronHost } from './host';
import { systemAuthService } from './system_auth';

import { screenshotPage } from './link_shot';
const __dirname = dirname(fileURLToPath(import.meta.url));

// Privileged-scheme registration must happen before app `ready`, and Electron
Expand Down Expand Up @@ -474,6 +474,9 @@ void app.whenReady().then(async () => {
setHost(electronHost);
installUpdateActions();
initAppContext();
// 链接卡片抓不到 og:image 时的兜底封面。截图要跑一个真浏览器,服务层不认识
// Electron,所以实现在这里注入(app_context 保持 Electron-free,web 端共用它)。
getAppContext().bootstrap?.linkPreview.setScreenshotHook(screenshotPage);
logger.info('electron app ready', { event: 'app-ready' });

registerResourceProtocol();
Expand Down
43 changes: 33 additions & 10 deletions apps/desktop/src/main/inject_elevation.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
/**
* Linux privilege-escalated injection — the `InjectHook` used on linux.
* Linux injection — the `InjectHook` used on linux.
*
* The instance key/rkey/clientkey flows need a QQ process that (a) has the hook
* injected and (b) has told the hook its MSF service address. On linux those are
* two distinct, differently-privileged steps:
*
* 1. INJECT (root) — ptrace-based, so it runs in a short-lived pkexec child
* (`inject_worker`). A graphical polkit password dialog pops once per pid.
* 1. INJECT (root) — ptrace-based. Under Electron (which refuses to run as
* root) this means a short-lived pkexec child (`inject_worker`), and a
* graphical polkit password dialog pops once per pid. When the host is
* already running as root — the web server on a headless box — we ptrace
* in-process instead; pkexec would be pointless and, with no polkit agent
* to authenticate against, impossible.
* 2. WAIT-FOR-PACKET (unprivileged) — the hook only learns the service
* address from a genuine post-login recv packet, so no OIDB packet can be
* sent until one arrives. This runs here in the main process (no root).
Expand Down Expand Up @@ -110,17 +114,28 @@ function pkexecInject(pid: number): Promise<void> {
}

/**
* Build the linux `InjectHook`: pkexec-elevated inject + unprivileged
* wait-for-packet, with per-pid idempotency backed by persisted records.
* Build the linux `InjectHook`: inject + unprivileged wait-for-packet, with
* per-pid idempotency backed by persisted records.
*
* The inject half is elevated only when it has to be. Electron refuses to run
* as root, so the desktop app is always unprivileged and must shell out to
* pkexec. The web server has no such constraint and is typically run as root
* on a headless box — where pkexec is both unnecessary (we already have the
* ptrace privilege) and unusable (no graphical polkit agent to authenticate
* against). So when euid is 0 we ptrace in-process instead.
*
* The wait-for-packet half is unprivileged either way and always runs here.
*
* @param userConfig Persists inject records to config.json so a WeQ restart
* reuses an already-hooked, still-running QQ instead of re-injecting it.
*/
export function createPkexecInjectHook(
export function createLinuxInjectHook(
nt: NtHelperBinding,
userConfig: UserConfigService,
): InjectHook {
/** pids whose pkexec ptrace inject has completed. */
const isRoot = process.geteuid?.() === 0;

/** pids whose ptrace inject has completed. */
const injected = new Set<number>();
/** pids that are injected AND have observed a real post-login packet. */
const ready = new Set<number>();
Expand Down Expand Up @@ -159,7 +174,7 @@ export function createPkexecInjectHook(
}
}

/** The pkexec ptrace inject half — pops the polkit dialog. Untimed by callers. */
/** The ptrace inject half — pops the polkit dialog unless we're already root. */
async function doInject(pid: number): Promise<void> {
if (injected.has(pid)) return;
const existing = injectInflight.get(pid);
Expand All @@ -168,8 +183,16 @@ export function createPkexecInjectHook(
return existing;
}
const task = (async (): Promise<void> => {
logger.info('injecting into qq via pkexec (root)', { event: 'inject-pkexec', pid });
await pkexecInject(pid);
if (isRoot) {
logger.info('injecting into qq in-process (already root)', {
event: 'inject-direct-root',
pid,
});
await nt.injectAndGetStatusEmbedded(pid);
} else {
logger.info('injecting into qq via pkexec (root)', { event: 'inject-pkexec', pid });
await pkexecInject(pid);
}
injected.add(pid);
// Persist so a WeQ restart reuses this hook instead of re-injecting.
// Skip if the pid vanished between inject and stat (record would be junk).
Expand Down
20 changes: 18 additions & 2 deletions apps/desktop/src/main/ipc/routers/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2349,9 +2349,18 @@ export const accountRouter = router({
* Returns `{ success:false, error }` for every failure mode (no model chosen,
* model not downloaded, silk missing, decode/engine error) so the bubble can
* show a friendly message instead of throwing.
*
* On success the text is written back onto the element's `pttTranscript`
* (wire tag 45923) when `msgId` is supplied — the same field QQ's own 转文字
* fills in, so the result survives a reload and QQ itself shows it.
*/
transcribeVoice: procedure
.input(z.object({ t: z.number(), name: z.string(), token: z.string().default('') }))
.input(z.object({
t: z.number(),
name: z.string(),
token: z.string().default(''),
msgId: z.string().default(''),
}))
.mutation(async ({ input }): Promise<{ success: boolean; text?: string; error?: string }> => {
const ctx = getAppContext();
const boot = ctx.bootstrap;
Expand Down Expand Up @@ -2393,6 +2402,13 @@ export const accountRouter = router({
{ engine: model.engine, languages: model.languages },
);
if (!result.success) return { success: false, error: result.error ?? '识别失败' };
return { success: true, text: result.text ?? '' };
const text = result.text ?? '';
if (input.msgId) {
// Best-effort: a failed back-write must not lose the text we just got.
await services.msgs
.setPttTranscript(BigInt(input.msgId), input.name, text)
.catch(() => false);
}
return { success: true, text };
}),
});
38 changes: 38 additions & 0 deletions apps/desktop/src/main/ipc/routers/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,44 @@ export const bootstrapRouter = router({
return true;
}),

/**
* 聊天里裸链接的展示方式。`enabled` 关掉后只做蓝色下划线、不出网;`screenshot`
* 决定页面没有 og:image 时要不要用离屏窗口截一张。纯持久化。
*/
setLinkPreview: procedure
.input(z.object({ enabled: z.boolean().optional(), screenshot: z.boolean().optional() }))
.mutation(({ input }) => {
requireBootstrap().userConfig.setSettings({ linkPreview: input });
return true;
}),

/**
* 取一条链接的预览卡片(标题/描述/站点/封面)。抓取全程带 SSRF 闸门 —— 只放行
* 公网 http(s) 的 80/443,重定向逐跳复检,正文只收 text/html(对方给二进制时
* body 根本不读)。结果按 URL 落盘缓存,命中不出网。不可预览返回 null。
*/
linkPreview: procedure
.input(z.object({ url: z.string().trim().max(2048) }))
.query(async ({ input }) => {
const boot = requireBootstrap();
if (!boot.userConfig.getSettings().linkPreview.enabled) return null;
return boot.linkPreview.get(input.url);
}),

/**
* 只落一张封面图(不抓页面),返回 `weq-media://linkpreview?id=` 用的缓存 id。
* 给「QQ 自己已经把标题/封面存在消息里」的那条路用 —— 元数据本地就有,缺的只是
* 图的字节。走的是抓取路径同一个闸门(公网 http(s) 80/443、魔数校验、大小上限),
* 因为这个 URL 一样来自不可信的聊天消息。拿不到返回空串。
*/
linkCover: procedure
.input(z.object({ url: z.string().trim().max(2048) }))
.query(async ({ input }) => {
const boot = requireBootstrap();
if (!boot.userConfig.getSettings().linkPreview.enabled) return '';
return boot.linkPreview.cacheCover(input.url);
}),

// ---- MCP server (account-bound) ----

/**
Expand Down
73 changes: 73 additions & 0 deletions apps/desktop/src/main/link_shot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* 网页预截图 —— 链接卡片抓不到 og:image 时的兜底封面。
*
* 一个 URL 走到这里,意味着我们真的要把陌生网页**跑起来**(脚本会执行)。所以这个窗口
* 按「一次性沙盒」造:
* · `show: false` + 离屏,用户看不到、也点不到;
* · 独立 `partition`(非 persist:)—— cookie / storage 进程退出即蒸发,不碰账号会话;
* · 无 preload、`nodeIntegration` 关、`contextIsolation` 开 —— 页面拿不到任何 bridge;
* · `setWindowOpenHandler` 一律 deny、`will-navigate` 只准同源跳转 —— 页面不能自己开窗
* 或把我们导去别处;
* · session 的 download 全部取消、权限请求(地理位置/摄像头/通知…)全部拒绝
* —— 「打开链接自动下载木马」在这里是走不通的:字节根本不落盘。
* · 8 秒硬超时,无论加载完没有都截图并销毁窗口,不留后台页面。
*
* 调用方是 LinkPreviewService(通过 setScreenshotHook 注入),URL 在那边已过完 SSRF 闸门。
*/

import { BrowserWindow, session } from 'electron';

const WIDTH = 1000;
const HEIGHT = 640;
const LOAD_TIMEOUT_MS = 8000;
/** 首屏之后再等一拍,让懒加载的图片/字体落位(截白图的主要原因)。 */
const SETTLE_MS = 900;

let seq = 0;

/** 在一次性沙盒窗口里加载 url 并截取首屏,返回 PNG 字节;失败返回 null。 */
export async function screenshotPage(url: string): Promise<Buffer | null> {
seq += 1;
const partition = `link-shot-${seq}`;
const ses = session.fromPartition(partition, { cache: false });
ses.setPermissionRequestHandler((_wc, _perm, cb) => cb(false));
// 页面若试图触发下载,直接掐掉——预览不需要任何文件落盘。
ses.on('will-download', (event) => event.preventDefault());

const win = new BrowserWindow({
width: WIDTH,
height: HEIGHT,
show: false,
webPreferences: {
partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
javascript: true,
offscreen: true,
images: true,
},
});
win.webContents.setAudioMuted(true);
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
const origin = new URL(url).origin;
win.webContents.on('will-navigate', (event, next) => {
if (!next.startsWith(origin)) event.preventDefault();
});

try {
const loaded = win.loadURL(url).catch(() => {});
await Promise.race([loaded, new Promise((r) => setTimeout(r, LOAD_TIMEOUT_MS))]);
if (win.isDestroyed()) return null;
await new Promise((r) => setTimeout(r, SETTLE_MS));
if (win.isDestroyed()) return null;
const image = await win.webContents.capturePage();
return image.isEmpty() ? null : image.toPNG();
} catch {
return null;
} finally {
if (!win.isDestroyed()) win.destroy();
void ses.clearStorageData().catch(() => {});
}
}
13 changes: 13 additions & 0 deletions apps/desktop/src/main/media_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
* weq-media://dressfont?id=<itemId> → 已安装的装扮字体 ttf
* weq-media://dressbubble?id=<itemId> → 走 protocol 装的气泡九宫格(本地 PNG)
* weq-media://dressbg?v=<stamp> → 用户自选的聊天背景(本地图)
* weq-media://linkpreview?id=<hash.ext> → 链接卡片封面(已落盘、验过魔数)
*
* Like the other custom schemes: `registerMediaScheme()` runs before app
* `ready`; `registerMediaProtocol()` runs after.
Expand Down Expand Up @@ -228,6 +229,18 @@ export function handleMediaRequest(request: Request): Promise<Response> {
}
}

// 链接卡片的封面图:字节是 LinkPreviewService 抓来验过魔数后落的盘,只按 id 取,
// 不接受 url —— 渲染层无法用它当任意 URL 的代理。同样不需要打开的账号。
if (kind === 'linkpreview') {
const svc = getAppContext().bootstrap?.linkPreview;
const blob = svc ? await svc.readImage(q.get('id') ?? '') : null;
if (!blob) return notFound('link preview image not found');
return new Response(new Uint8Array(blob.data), {
status: 200,
headers: { 'Content-Type': blob.contentType, 'Cache-Control': 'public, max-age=86400' },
});
}

const services = getAppContext().services;
if (!services) return notFound('no account session');

Expand Down
Loading
Loading