diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50c9f20f..9e868722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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) ### 变更 diff --git a/apps/desktop/src/main/context/app_context.ts b/apps/desktop/src/main/context/app_context.ts index 02aef54e..afc8d3c0 100644 --- a/apps/desktop/src/main/context/app_context.ts +++ b/apps/desktop/src/main/context/app_context.ts @@ -30,9 +30,9 @@ 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, @@ -40,6 +40,7 @@ import { Win32KeyService, GlobalConfigService, AvatarCacheService, + LinkPreviewService, AgentLabConfigService, VoiceTranscribeService, TtsService, @@ -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; @@ -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(), diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 6e7bc656..5c28dc6d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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 @@ -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(); diff --git a/apps/desktop/src/main/inject_elevation.ts b/apps/desktop/src/main/inject_elevation.ts index 526a6643..a413b0c9 100644 --- a/apps/desktop/src/main/inject_elevation.ts +++ b/apps/desktop/src/main/inject_elevation.ts @@ -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). @@ -110,17 +114,28 @@ function pkexecInject(pid: number): Promise { } /** - * 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(); /** pids that are injected AND have observed a real post-login packet. */ const ready = new Set(); @@ -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 { if (injected.has(pid)) return; const existing = injectInflight.get(pid); @@ -168,8 +183,16 @@ export function createPkexecInjectHook( return existing; } const task = (async (): Promise => { - 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). diff --git a/apps/desktop/src/main/ipc/routers/account.ts b/apps/desktop/src/main/ipc/routers/account.ts index d954153b..8a4c0b91 100644 --- a/apps/desktop/src/main/ipc/routers/account.ts +++ b/apps/desktop/src/main/ipc/routers/account.ts @@ -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; @@ -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 }; }), }); diff --git a/apps/desktop/src/main/ipc/routers/bootstrap.ts b/apps/desktop/src/main/ipc/routers/bootstrap.ts index cf5e4481..127ccf13 100644 --- a/apps/desktop/src/main/ipc/routers/bootstrap.ts +++ b/apps/desktop/src/main/ipc/routers/bootstrap.ts @@ -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) ---- /** diff --git a/apps/desktop/src/main/link_shot.ts b/apps/desktop/src/main/link_shot.ts new file mode 100644 index 00000000..901607c7 --- /dev/null +++ b/apps/desktop/src/main/link_shot.ts @@ -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 { + 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(() => {}); + } +} diff --git a/apps/desktop/src/main/media_protocol.ts b/apps/desktop/src/main/media_protocol.ts index 37e62a83..4ac448ae 100644 --- a/apps/desktop/src/main/media_protocol.ts +++ b/apps/desktop/src/main/media_protocol.ts @@ -24,6 +24,7 @@ * weq-media://dressfont?id= → 已安装的装扮字体 ttf * weq-media://dressbubble?id= → 走 protocol 装的气泡九宫格(本地 PNG) * weq-media://dressbg?v= → 用户自选的聊天背景(本地图) + * weq-media://linkpreview?id= → 链接卡片封面(已落盘、验过魔数) * * Like the other custom schemes: `registerMediaScheme()` runs before app * `ready`; `registerMediaProtocol()` runs after. @@ -228,6 +229,18 @@ export function handleMediaRequest(request: Request): Promise { } } + // 链接卡片的封面图:字节是 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'); diff --git a/apps/desktop/src/main/stub_elevation.ts b/apps/desktop/src/main/stub_elevation.ts index 5510d120..16234acd 100644 --- a/apps/desktop/src/main/stub_elevation.ts +++ b/apps/desktop/src/main/stub_elevation.ts @@ -1,18 +1,21 @@ /** - * Linux privilege-escalated stub hooks for ninebird. + * Linux stub hooks for ninebird, elevating only when the host isn't root. * * The ninebird launch flow drops a tiny entry stub (`loadNineBird.js`) into * QQ's `resources/app` so QQ's Electron entry resolves a real file (a raw * statx probe that `LD_PRELOAD` can't fake). That directory is root-owned * (root:root 0755) on a normal QQ install, so writing the stub needs - * elevation. We shell out to `pkexec`, which pops the desktop's graphical - * polkit auth dialog. + * elevation — unless we're already root, in which case a plain `fs` write + * does. Electron refuses to run as root, so the desktop app always takes the + * elevated path; the headless web server usually doesn't need it. * - * Frequency is low — a given account only needs a dbkey once — so a password - * prompt per drop is acceptable. We deliberately do NOT clean the stub up: - * polkit's default policy for `org.freedesktop.policykit.exec` is `auth_admin` - * (no credential caching), so a later cleanup would just pop the dialog again. - * The stub is a harmless self-`require` shim; the next drop overwrites it. + * When elevation IS needed we shell out to `pkexec`, which pops the desktop's + * graphical polkit auth dialog. Frequency is low — a given account only needs + * a dbkey once — so a password prompt per drop is acceptable. We deliberately + * do NOT clean the stub up in that path: polkit's default policy for + * `org.freedesktop.policykit.exec` is `auth_admin` (no credential caching), so + * a later cleanup would just pop the dialog again. The stub is a harmless + * self-`require` shim; the next drop overwrites it. * * Windows never uses these hooks — `@weq/native` falls back to a direct `fs` * write there. @@ -70,8 +73,10 @@ function pkexecWriteFile(path: string, content: string): Promise { * StubHooks backed by pkexec. `removeStub` is intentionally a no-op (see the * module header): the stub is harmless and left in place, overwritten on the * next drop. + * + * Only used when the host is NOT already root — see `linuxStubHooks`. */ -export const pkexecStubHooks: StubHooks = { +const pkexecStubHooks: StubHooks = { dropStub: async (path: string, content: string): Promise => { logger.info('dropping ninebird entry stub via pkexec', { event: 'stub-drop-pkexec', @@ -83,3 +88,13 @@ export const pkexecStubHooks: StubHooks = { /* intentionally not cleaned up — see module header */ }, }; + +/** + * The linux stub hooks. Running as root (typical for the headless web server) + * means we can write QQ's root-owned `resources/app` directly — `undefined` + * selects `@weq/native`'s plain-`fs` default, which also cleans the stub up. + * Only an unprivileged host (the desktop app, since Electron refuses to run as + * root) needs the pkexec detour. + */ +export const linuxStubHooks: StubHooks | undefined = + process.geteuid?.() === 0 ? undefined : pkexecStubHooks; diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 4f2e0017..5ba81fb7 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -19,7 +19,7 @@ import { VideoLightbox } from './components/VideoLightbox'; import { MarketFaceLightbox } from './components/MarketFaceLightbox'; import { ForwardWindowHost } from './components/ForwardWindow'; import { AppLockOverlay } from './components/AppLockOverlay'; -import { TextMarkdownContext } from './components/QqMessageContent'; +import { TextMarkdownContext, LinkPreviewContext } from './components/QqMessageContent'; import { SelfPendantContext } from './hooks/useSelfPendant'; import { WarmupSplash } from './components/WarmupSplash'; import { trpc } from './trpc/client'; @@ -30,7 +30,7 @@ import { usePrivacyStore } from './state/privacy'; import { useAccountSwitch } from './state/accountSwitch'; /** - * 把「纯文本消息渲染 Markdown」开关广播给所有消息气泡。 + * 把两个「气泡渲染」开关广播给所有消息气泡:纯文本 Markdown、链接预览卡片。 * * 查询只在这一层做一次——QqMessageContent 每条消息一个实例,让它们各自 useQuery 会挂 * 几百个订阅。必须包住 ForwardWindowHost(它在 MainView 之外,转发窗口里的气泡同样 @@ -44,7 +44,9 @@ function TextMarkdownProvider({ children }: { children: ReactNode }): ReactEleme }); return ( - {children} + + {children} + ); } diff --git a/apps/desktop/src/renderer/src/components/QqLinkCard.tsx b/apps/desktop/src/renderer/src/components/QqLinkCard.tsx new file mode 100644 index 00000000..77a6d0da --- /dev/null +++ b/apps/desktop/src/renderer/src/components/QqLinkCard.tsx @@ -0,0 +1,121 @@ +/** + * 链接卡片 —— 一条消息只有一个裸链接时,在气泡下方补一张「标题 + 描述 + 封面 + 站点」。 + * + * 两个数据源,优先级从高到低: + * 1. `info` —— QQ 服务端扫描链接时随消息一起存下来的元数据(text 元素 wire tag + * 45112,见 codec 的 decodeUrlVerify)。本地就有,直接画,一个字节都不出网。 + * 2. LinkPreviewService —— 主进程去抓 og/twitter meta(公众号另走 msg_* 内联变量), + * 带 SSRF 闸门和内容类型白名单。只在 1 缺席时才发 query。 + * + * 两个都拿不到就返回 null —— 链接文本本身已经由调用方画在气泡里了,这里不必再兜底。 + * + * 视觉沿用 ARK 卡片那一套 class(weq-ark-*),因为它们在聊天流里已经是「卡片」的既定 + * 语言,没必要再造一套。 + */ + +import { memo, type ReactElement } from 'react'; +import { Link2 } from 'lucide-react'; +import type { UrlVerifyInfo } from '@weq/service'; +import { trpc } from '../trpc/client'; +import { linkPreviewImageUrl } from '../lib/resourceUrl'; +import { openLink } from '../lib/linkify'; + +/** 卡片实际需要的几个字段,抹平「QQ 自带」与「自己抓」两种来源。 */ +interface CardData { + url: string; + title: string; + desc: string; + siteName: string; + /** 已经可以直接塞进 的地址(本地缓存或代理)。 */ + cover: string; + /** og 图按常规裁切,整页截图按顶部对齐。 */ + imageKind: 'og' | 'shot' | ''; +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +export const QqLinkCard = memo(function QqLinkCard({ + url, + info, +}: { + url: string; + info?: UrlVerifyInfo; +}): ReactElement | null { + // 结果在主进程按 URL 落盘缓存,所以这里放心地按 url 做 query key;抓取失败返回 + // null(短 TTL 内不再重试)。QQ 已经给了元数据时整个 query 都不发。 + const preview = trpc.bootstrap.linkPreview.useQuery( + { url }, + { enabled: !info, staleTime: Number.POSITIVE_INFINITY, retry: false }, + ); + // QQ 给的封面只是个远端地址,字节还得主进程去取(同一套 SSRF 闸门 + 魔数校验), + // 落盘后按 id 走 weq-media://linkpreview。没图 / 取不到就不画图,卡片照常出。 + const coverUrl = info?.imageUrl ?? ''; + const cover = trpc.bootstrap.linkCover.useQuery( + { url: coverUrl }, + { enabled: Boolean(coverUrl), staleTime: Number.POSITIVE_INFINITY, retry: false }, + ); + + let data: CardData | null = null; + if (info) { + data = { + url, + title: info.title, + // QQ 给的 desc 在页面没写描述时会退化成主机名,那就跟页脚重复了,不如不显示。 + desc: info.desc === hostOf(url) ? '' : info.desc, + // 站点名 QQ 没给,从地址取 host。 + siteName: hostOf(url), + cover: cover.data ? linkPreviewImageUrl(cover.data) : '', + imageKind: 'og', + }; + } else if (preview.data) { + const p = preview.data; + data = { + url: p.url, + title: p.title, + desc: p.desc, + siteName: p.siteName, + cover: p.image ? linkPreviewImageUrl(p.image) : '', + imageKind: p.imageKind, + }; + } + + if (!data) return null; + + const card = data; + return ( +
openLink(card.url)} + > +
+
{card.title || card.url}
+ {card.cover ? ( + <> + {card.desc ?
{card.desc}
: null} + + + ) : ( +
{card.desc || card.url}
+ )} +
+
+ + {card.siteName} +
+
+ ); +}); diff --git a/apps/desktop/src/renderer/src/components/QqMedia.tsx b/apps/desktop/src/renderer/src/components/QqMedia.tsx index cff52f67..e87392a4 100644 --- a/apps/desktop/src/renderer/src/components/QqMedia.tsx +++ b/apps/desktop/src/renderer/src/components/QqMedia.tsx @@ -352,7 +352,7 @@ export function QqOnlineFile({ data, kind }: { data: Data; kind: 'file' | 'folde // ---- voice (ptt) -------------------------------------------------------- -export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number }) { +export function QqVoice({ data, sendTimeMs, msgId }: { data: Data; sendTimeMs: number; msgId: string }) { const name = str(data, 'fileName'); const token = str(data, 'fileToken'); const waveform = Array.isArray(data.waveform) ? (data.waveform as number[]) : []; @@ -375,6 +375,9 @@ export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number } }); const canTranscribe = Boolean(settings.data?.voiceTranscribe.modelId); const transcribe = trpc.account.transcribeVoice.useMutation(); + // A transcript already on the element (wire tag 45923) — written either by + // QQ's own 转文字 or by a previous WeQ run, which writes back to the same field. + const storedTranscript = str(data, 'pttTranscript'); const [transcript, setTranscript] = useState(null); const [transcribeError, setTranscribeError] = useState(null); @@ -410,7 +413,7 @@ export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number } if (transcribe.isLoading) return; setTranscribeError(null); transcribe - .mutateAsync({ t: sendTimeMs, name, token }) + .mutateAsync({ t: sendTimeMs, name, token, msgId }) .then((res) => { if (res.success) setTranscript(res.text ?? ''); else setTranscribeError(res.error ?? '识别失败'); @@ -424,7 +427,8 @@ export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number } // wave area also clips as a hard safety net; see .qq-media-voice-wave). const barCount = Math.max(12, Math.min(28, 8 + Math.round(seconds))); const bars = sampleBars(waveform, barCount); - const hasResult = transcript !== null || transcribeError !== null; + const shownTranscript = transcript ?? (storedTranscript || null); + const hasResult = shownTranscript !== null || transcribeError !== null; return (
@@ -474,7 +478,7 @@ export function QqVoice({ data, sendTimeMs }: { data: Data; sendTimeMs: number } {hasResult ? (
- {transcribeError ?? (transcript ? transcript : '(未识别到内容)')} + {transcribeError ?? (shownTranscript ? shownTranscript : '(未识别到内容)')}
) : null}
diff --git a/apps/desktop/src/renderer/src/components/QqMessageContent.tsx b/apps/desktop/src/renderer/src/components/QqMessageContent.tsx index d64e1f70..d5be1500 100644 --- a/apps/desktop/src/renderer/src/components/QqMessageContent.tsx +++ b/apps/desktop/src/renderer/src/components/QqMessageContent.tsx @@ -18,6 +18,7 @@ import { createContext, useContext, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { ArrowUp } from 'lucide-react'; +import type { UrlVerifyInfo } from '@weq/service'; import type { MessageRenderer } from '../im-template/template'; import { FaceEmoji } from './FaceEmoji'; import { QqImage, QqVideo, QqFile, QqVoice, QqMarketFace, QqOnlineFile } from './QqMedia'; @@ -30,6 +31,8 @@ import { QqCall } from './QqCall'; import { QqShareLocation } from './QqShareLocation'; import { QqDynamic } from './QqDynamic'; import { QqEmojiBounce } from './QqEmojiBounce'; +import { QqLinkCard } from './QqLinkCard'; +import { splitLinks, soleLink, openLink } from '../lib/linkify'; import { cn } from '@renderer/lib/utils'; /** @@ -74,6 +77,13 @@ export const ConvContext = createContext(''); */ export const TextMarkdownContext = createContext(true); +/** + * 「整条消息只有一个链接时出预览卡片」开关(AppSettings.linkPreview.enabled)。 + * 同 {@link TextMarkdownContext} 走 context 的理由——每条气泡一个实例,不能各自订阅。 + * 关掉后链接仍会标蓝可点,只是不再抓取远端页面(也就完全不出网)。 + */ +export const LinkPreviewContext = createContext(true); + /** Element kinds that render as standalone, borderless media (no bubble). */ const BORDERLESS_MEDIA = new Set(['pic', 'video', 'mface']); /** Element kinds handled by a dedicated media component. */ @@ -178,7 +188,7 @@ function MediaNode({ case 'file': return ; case 'ptt': - return ; + return ; case 'mface': return ; case 'onlineFile': @@ -249,7 +259,36 @@ function ElementNode({ ); } const text = inlineLabel(element); - return text ? {text} : null; + if (!text) return null; + // 纯文本里的链接标蓝加下划线并可点开(走系统浏览器,见 lib/linkify)。用 + ) : ( + // biome-ignore lint/suspicious/noArrayIndexKey: 按位置切分,无稳定唯一键 + {part.text} + ), + )} + + ); } /** @@ -502,6 +541,7 @@ export function QqMessageContent({ const arkElement = elements.find((element) => element.type === 'ark'); const forwardKind = useContext(ForwardKindContext); const textMarkdownOn = useContext(TextMarkdownContext); + const linkPreviewOn = useContext(LinkPreviewContext); if (arkElement && isArkMultiMsg(arkElement.data?.arkData)) { return (
@@ -671,6 +711,33 @@ export function QqMessageContent({ } } + // 整条消息**只是**一个链接 → 在气泡下方补一张预览卡(标题/封面/站点)。混了别的 + // 文字就不出卡,那种消息里链接只是句子的一部分,行内标蓝就够了。 + // + // 卡片挂在气泡下面(而不是取代气泡)—— 同语音转录的处理:原文始终看得见,卡片只是 + // 附加信息。数据优先取 QQ 自己扫出来的 urlVerify(本地就有,不出网),没有才让 + // QqLinkCard 去抓;两个都没有它返回 null,只剩气泡里那条蓝链接。 + const soleLinkCard = ((): ReactNode => { + if (!linkPreviewOn) return null; + if (meaningful.length === 0 || !meaningful.every((element) => element.type === 'text')) return null; + const body = meaningful.map((element) => String(element.data?.textContent ?? '')).join(''); + const only = soleLink(body); + if (!only) return null; + const verified = meaningful + .map((element) => element.data?.urlVerify as UrlVerifyInfo | undefined) + .find(Boolean); + return ; + })(); + + if (soleLinkCard) { + return ( +
+ {renderElementNodes(meaningful, sendTimeMs, msgId, isSender)} + {soleLinkCard} +
+ ); + } + // WeQ feature(可在设置里关掉):一条**全是纯文本**的消息,如果看起来像 Markdown, // 就整段交给 streamdown。两道闸都是必需的: // · 全 text —— 一旦混有 at/face,整段当 Markdown 渲染会吞掉 @ 高亮和表情图; diff --git a/apps/desktop/src/renderer/src/components/SettingsDialog.tsx b/apps/desktop/src/renderer/src/components/SettingsDialog.tsx index fabc5282..60e22d31 100644 --- a/apps/desktop/src/renderer/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/renderer/src/components/SettingsDialog.tsx @@ -203,8 +203,11 @@ function AppearanceSection(): ReactElement { }); const setRenderTextMarkdown = trpc.bootstrap.setRenderTextMarkdown.useMutation(); const setShowAvatarPendant = trpc.bootstrap.setShowAvatarPendant.useMutation(); + const setLinkPreview = trpc.bootstrap.setLinkPreview.useMutation(); const [textMarkdown, setTextMarkdown] = useState(true); const [pendant, setPendant] = useState(true); + const [linkCard, setLinkCard] = useState(true); + const [linkShot, setLinkShot] = useState(false); useEffect(() => { const enabled = settings.data?.renderTextMarkdown; @@ -216,6 +219,12 @@ function AppearanceSection(): ReactElement { if (typeof enabled === 'boolean') setPendant(enabled); }, [settings.data?.showAvatarPendant]); + useEffect(() => { + const cfg = settings.data?.linkPreview; + if (typeof cfg?.enabled === 'boolean') setLinkCard(cfg.enabled); + if (typeof cfg?.screenshot === 'boolean') setLinkShot(cfg.screenshot); + }, [settings.data?.linkPreview]); + // 本地先翻转求手感,失败回滚 + 重新拉服务端值。 async function onSetTextMarkdown(next: boolean): Promise { const prev = textMarkdown; @@ -239,6 +248,28 @@ function AppearanceSection(): ReactElement { await settings.refetch(); } + async function onSetLinkCard(next: boolean): Promise { + const prev = linkCard; + setLinkCard(next); + try { + await setLinkPreview.mutateAsync({ enabled: next }); + } catch { + setLinkCard(prev); + } + await settings.refetch(); + } + + async function onSetLinkShot(next: boolean): Promise { + const prev = linkShot; + setLinkShot(next); + try { + await setLinkPreview.mutateAsync({ screenshot: next }); + } catch { + setLinkShot(prev); + } + await settings.refetch(); + } + return (

个性显示

@@ -319,6 +350,40 @@ function AppearanceSection(): ReactElement { />
+
+
+
+ 链接预览卡片 + + 一条消息只有一个链接时,抓取网页标题、简介和封面渲染成卡片。会按链接访问 + 对应网页(结果本地缓存),关掉后链接仍标蓝可点,但完全不出网。 + +
+ void onSetLinkCard(next)} + label="链接预览卡片" + /> +
+
+
+
+
+ 无封面时网页截图 + + 网页没提供封面图时,在一个隔离的后台窗口里把它打开并截取首屏当封面。这会 + 真正运行对方页面的脚本(沙盒内、不落盘、退出即清),默认关闭。 + +
+ void onSetLinkShot(next)} + label="无封面时网页截图" + /> +
+
); } diff --git a/apps/desktop/src/renderer/src/lib/linkify.ts b/apps/desktop/src/renderer/src/lib/linkify.ts new file mode 100644 index 00000000..5f0c225d --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/linkify.ts @@ -0,0 +1,108 @@ +/** + * 聊天文本里的链接识别与打开。 + * + * 两件事:把一段纯文本切成「文字 / 链接」片段({@link splitLinks}),以及安全地把一个 + * 链接交给系统浏览器({@link openLink})。 + * + * 打开这一侧的边界: + * · 只放行 http(s)。`file://` / `javascript:` / `mqqapi://` 这类一律不开——聊天文本 + * 是完全不可信的输入,让它触发本机协议处理器等于把攻击面直接送出去。 + * · 用户点的链接若指向可执行/安装包后缀,先弹一次确认。浏览器不会自动运行下载物, + * 但「点一下就开始下 .exe」本身值得让人先看清域名——这一步是给人看的,不是给 + * 浏览器看的。 + * · 走 `window.open` → 主进程 `setWindowOpenHandler` 已把所有 target=_blank 转成 + * `shell.openExternal` 并 deny 掉窗口,所以应用内不会有任何远程页面被加载。 + */ + +/** + * 链接匹配。刻意不追求 RFC 完备,只认聊天里真正会出现的两种写法:带 scheme 的 + * http(s),以及裸 `www.` 开头的域名。写成无回溯的字符类扫描(避免 catastrophic + * backtracking——这条正则要跑在每一条消息上)。 + */ +const LINK_RE = /(?:https?:\/\/|www\.)[^\s<>"'“”,。!?;:、)】》]+/gi; + +/** 结尾的成对标点通常是句子的一部分而非 URL 的(`(见 https://a.com/x)`)。 */ +function trimTrailing(raw: string): string { + let out = raw; + while (out.length > 0) { + const last = out[out.length - 1]!; + if ('.,;:!?)]}\'"'.includes(last)) { + // 括号只在没有配对的开括号时才剥掉(维基链接常自带括号)。 + if (last === ')' && (out.match(/\(/g)?.length ?? 0) >= (out.match(/\)/g)?.length ?? 0)) break; + out = out.slice(0, -1); + continue; + } + break; + } + return out; +} + +export interface LinkPart { + kind: 'text' | 'link'; + /** 原文(链接片段保留用户写的原样,用于显示)。 */ + text: string; + /** 链接片段的规范化地址(裸 www. 补上 https://);文字片段为空串。 */ + href: string; +} + +/** 把一段文本切成文字/链接片段。没有链接时返回单个 text 片段。 */ +export function splitLinks(text: string): LinkPart[] { + const parts: LinkPart[] = []; + let cursor = 0; + LINK_RE.lastIndex = 0; + let m = LINK_RE.exec(text); + while (m) { + const raw = trimTrailing(m[0]); + if (raw.length >= 8) { + const start = m.index; + if (start > cursor) parts.push({ kind: 'text', text: text.slice(cursor, start), href: '' }); + const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; + parts.push({ kind: 'link', text: raw, href }); + cursor = start + raw.length; + } + LINK_RE.lastIndex = m.index + m[0].length; + m = LINK_RE.exec(text); + } + if (cursor < text.length) parts.push({ kind: 'text', text: text.slice(cursor), href: '' }); + return parts; +} + +/** 整段文本是否**只是**一个链接(决定要不要出卡片)。 */ +export function soleLink(text: string): string | null { + const parts = splitLinks(text.trim()); + const links = parts.filter((p) => p.kind === 'link'); + if (links.length !== 1) return null; + const rest = parts + .filter((p) => p.kind === 'text') + .map((p) => p.text.trim()) + .join(''); + return rest === '' ? links[0]!.href : null; +} + +/** 点开会直接开始下载的后缀 —— 打开前要用户再确认一次。 */ +const RISKY_EXT = + /\.(exe|msi|msix|appx|bat|cmd|com|scr|pif|ps1|vbs|vbe|js|jse|wsf|hta|jar|apk|dmg|pkg|deb|rpm|sh|run|iso|img|lnk|reg|dll|zip|rar|7z|gz)(?:$|[?#])/i; + +/** + * 用系统浏览器打开一个链接。非 http(s) 直接拒绝;指向可执行/压缩包后缀时先确认。 + * 返回是否真的打开了。 + */ +export function openLink(href: string): boolean { + let url: URL; + try { + url = new URL(href); + } catch { + return false; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + if (RISKY_EXT.test(url.pathname + url.search)) { + const name = decodeURIComponent(url.pathname.split('/').pop() ?? ''); + const ok = window.confirm( + `这个链接指向一个可执行文件或压缩包,打开后浏览器可能直接开始下载。\n\n` + + `站点:${url.hostname}\n文件:${name}\n\n确定要在浏览器中打开吗?`, + ); + if (!ok) return false; + } + window.open(url.toString(), '_blank', 'noopener,noreferrer'); + return true; +} diff --git a/apps/desktop/src/renderer/src/lib/resourceUrl.ts b/apps/desktop/src/renderer/src/lib/resourceUrl.ts index c82d7d55..2b2dd6f7 100644 --- a/apps/desktop/src/renderer/src/lib/resourceUrl.ts +++ b/apps/desktop/src/renderer/src/lib/resourceUrl.ts @@ -121,8 +121,16 @@ export function screenWidgetUrl(widgetId: string, ...segments: string[]): string return resourceUrl('dress', 'screen', widgetId, ...segments); } -/** Preview a local file under `nt_data/File/Ori` by absolute path (image thumbnails). */ -export function localFileUrl(absPath: string): string { +/** + * 链接卡片的封面图。`id` 是主进程落盘时给的缓存名 —— 这里刻意**不接受 URL**: + * 图片字节由 LinkPreviewService 抓取并验过魔数,渲染层只能取已缓存的那些, + * 不能拿这条协议当任意 URL 的代理。 + */ +export function linkPreviewImageUrl(id: string): string { + return mediaUrl('linkpreview', { id }); +} + +/** Preview a local file under `nt_data/File/Ori` by absolute path (image thumbnails). */export function localFileUrl(absPath: string): string { return mediaUrl('localfile', { path: absPath }); } diff --git a/apps/desktop/src/renderer/src/styles/index.css b/apps/desktop/src/renderer/src/styles/index.css index ac0fdc2c..332b38ab 100644 --- a/apps/desktop/src/renderer/src/styles/index.css +++ b/apps/desktop/src/renderer/src/styles/index.css @@ -6636,9 +6636,37 @@ html[data-theme="dark"] .weq-ark-contact-avatar { background: rgba(255, 255, 255 html[data-theme="dark"] .weq-ark-contact-name { color: var(--weq-fg-primary); } html[data-theme="dark"] .weq-ark-contact-sub { color: var(--weq-fg-muted); } +/* ---- 聊天文本里的链接 ---- 用