From 700f3e4b069db57275de8c0acf467e4dcd508810 Mon Sep 17 00:00:00 2001 From: H3CoF6 Date: Sun, 2 Aug 2026 04:33:01 +0800 Subject: [PATCH 1/2] fix: sysEmoji fallback default database content --- .../src/views/cache/SysEmojiExplorer.tsx | 1 + packages/db/tools/gen_sys_emoji_fallback.ts | 128 ++++++++++++++++++ .../service/src/account/sys_emoji_download.ts | 32 ++++- .../service/src/account/sys_emoji_fallback.ts | 92 +++++++++++++ 4 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 packages/db/tools/gen_sys_emoji_fallback.ts create mode 100644 packages/service/src/account/sys_emoji_fallback.ts diff --git a/apps/desktop/src/renderer/src/views/cache/SysEmojiExplorer.tsx b/apps/desktop/src/renderer/src/views/cache/SysEmojiExplorer.tsx index 0aa672b..b6cb742 100644 --- a/apps/desktop/src/renderer/src/views/cache/SysEmojiExplorer.tsx +++ b/apps/desktop/src/renderer/src/views/cache/SysEmojiExplorer.tsx @@ -134,6 +134,7 @@ export function SysEmojiExplorer(): ReactElement { {total ?? entries.length} 个 · 默认动图(APNG)预览,点击查看全部格式 {status && !status.qqRoot ? ' · QQ 资源目录缺失,已改用下载补全' : ''} + {status?.usingFallback ? ' · emoji.db 无表情数据,用内置地址表兜底' : ''} {missing > 0 ? (
{entry.hash.slice(0, 8)}… + {canTranscribe && !hasResult ? ( + + ) : null} {fmtBytes(entry.bytes)}
+ {hasResult ? ( +
+ {transcribeError ?? (transcript || '(未识别到语音内容)')} +
+ ) : null} ); } diff --git a/packages/platform/src/linux/index.ts b/packages/platform/src/linux/index.ts index 5211578..1dd7d1c 100644 --- a/packages/platform/src/linux/index.ts +++ b/packages/platform/src/linux/index.ts @@ -82,11 +82,6 @@ export function createLinuxPlatform( const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config'); return join(base, 'weq'); }, - avatarCacheDir: () => { - const xdg = process.env.XDG_CACHE_HOME; - const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache'); - return join(base, 'weq', 'avatar'); - }, tencentFilesRoots: () => candidateQqRoots(home, override()), loginDbPath: () => findLoginDb(home, override()), accountDir: (u: string) => findAccountDir(uid(u), home, override()), diff --git a/packages/platform/src/types.ts b/packages/platform/src/types.ts index 347ece4..65fc196 100644 --- a/packages/platform/src/types.ts +++ b/packages/platform/src/types.ts @@ -23,14 +23,6 @@ export interface Platform { */ appDataRoot(): string; - /** - * Default on-disk directory for the avatar cache. On Windows this is - * `%APPDATA%\weq\cache\avatar`. Per-OS so the cache lands in the platform's - * conventional location; the global config may override it. NOT guaranteed - * to exist — the cache service `mkdir -p`s before writing. - */ - avatarCacheDir(): string; - /** * Candidate roots that may contain `/nt_qq/...` directories. Resolved * synchronously from well-known locations on this OS — no I/O needed by diff --git a/packages/platform/src/win32/index.ts b/packages/platform/src/win32/index.ts index 2acea1e..8a03241 100644 --- a/packages/platform/src/win32/index.ts +++ b/packages/platform/src/win32/index.ts @@ -87,13 +87,6 @@ export function createWin32Platform( } return join(base, 'weq'); }, - avatarCacheDir: () => { - const base = process.env.APPDATA; - if (!base) { - throw new Error('%APPDATA% not set — cannot derive weq avatar cache dir on win32'); - } - return join(base, 'weq', 'cache', 'avatar'); - }, tencentFilesRoots: () => candidateTencentFilesRoots(undefined, override()), loginDbPath: () => findLoginDb(undefined, override()), accountDir: (uin: string) => findAccountDir(uin, undefined, override()), diff --git a/packages/service/src/bootstrap/avatar_cache.ts b/packages/service/src/bootstrap/avatar_cache.ts index 76eb409..15dd64e 100644 --- a/packages/service/src/bootstrap/avatar_cache.ts +++ b/packages/service/src/bootstrap/avatar_cache.ts @@ -7,23 +7,19 @@ * we * * 1. hash the URL → a stable filename under the cache dir, - * 2. serve the cached bytes if present, + * 2. serve the cached bytes if present and not past {@link TTL_MS}, * 3. otherwise fetch upstream once, persist, and return the bytes. * * Concurrent requests for the same URL share a single in-flight fetch (so a * screen that mounts 50 identical avatars hits the network once). * - * Cache directory resolution (override wins, default never hard-coded here): - * `UserConfig.avatarCacheDir` → `platform.avatarCacheDir()` - * * The transport (a custom protocol) lives in the desktop app; this service is * transport-agnostic and just deals in URLs → bytes. */ import { createHash } from 'node:crypto'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { readFile, stat, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import type { Platform } from '@weq/platform'; import type { UserConfigService } from './user_config'; /** Bytes + content type for one resolved avatar. */ @@ -65,19 +61,34 @@ function contentTypeForExt(ext: string): string { } } +/** + * How long a cached avatar stays fresh. QQ's avatar URLs carry no version + * (`…/g?b=sdk&nk=&s=0` is the same string before and after the user + * changes their picture), so the sha1(url) key can never invalidate itself — + * without an expiry a changed avatar would show the old bytes forever. Seven + * days trades a weekly re-fetch per avatar for a bounded staleness window. + * + * The LOCAL `nt_data/avatar` path (see AvatarResourceService) deliberately has + * no TTL: its filename is derived from the uid, so QQ overwrites the same file + * in place and our next read already sees the new bytes. + */ +const TTL_MS = 7 * 24 * 60 * 60 * 1000; + export class AvatarCacheService { /** De-dupe concurrent fetches of the same upstream URL. */ private readonly inFlight = new Map>(); - constructor( - private readonly platform: Platform, - private readonly userConfig: UserConfigService, - ) {} + constructor(private readonly userConfig: UserConfigService) {} - /** Effective cache directory: config override, else the platform default. */ + /** + * Cache directory — the shared `/avatar` that the 清理缓存 UI + * enumerates and wipes (`UserConfigService.clearCache`). It MUST be derived + * from `cacheBaseDir()` so a user-set cache-dir override moves both the + * writes and the cleanup; a separately-resolved path would leave the old + * directory growing forever while the UI reported 0 bytes. + */ cacheDir(): string { - const override = this.userConfig.read().avatarCacheDir; - return override?.trim() ? override : this.platform.avatarCacheDir(); + return this.userConfig.cacheDir('avatar'); } /** @@ -113,16 +124,24 @@ export class AvatarCacheService { } /** - * Return cached bytes for `url` if any extension variant exists on disk. - * Async (non-blocking) so dozens of concurrent avatar requests interleave on - * the main-process event loop instead of each one blocking it in turn — we - * just attempt each extension's read and treat a miss as ENOENT. + * Return cached bytes for `url` if any extension variant exists on disk and + * is still within {@link TTL_MS}. Async (non-blocking) so dozens of concurrent + * avatar requests interleave on the main-process event loop instead of each + * one blocking it in turn — we just attempt each extension's read and treat a + * miss as ENOENT. An expired file is deleted so the refetch below writes a + * clean entry (and a different extension can't shadow it). */ private async readFromDisk(url: string): Promise { const base = this.basePath(url); for (const ext of ['png', 'jpg', 'gif', 'webp']) { + const path = `${base}.${ext}`; try { - const data = await readFile(`${base}.${ext}`); + const st = await stat(path); + if (Date.now() - st.mtimeMs > TTL_MS) { + await unlink(path).catch(() => {}); + continue; + } + const data = await readFile(path); return { data, contentType: contentTypeForExt(ext), fromCache: true }; } catch { // Missing (ENOENT) or unreadable — try the next extension. @@ -145,8 +164,6 @@ export class AvatarCacheService { } const ext = extFor(contentType, url); - const dir = this.cacheDir(); - await mkdir(dir, { recursive: true }); try { await writeFile(`${this.basePath(url)}.${ext}`, data); } catch { diff --git a/packages/service/src/bootstrap/user_config.ts b/packages/service/src/bootstrap/user_config.ts index 4038a68..452c8e7 100644 --- a/packages/service/src/bootstrap/user_config.ts +++ b/packages/service/src/bootstrap/user_config.ts @@ -231,7 +231,6 @@ export const DEFAULT_APP_SETTINGS: AppSettings = { export interface UserConfig { autoEnter?: AutoEnterTarget | null; tencentFilesRootOverride?: string | null; - avatarCacheDir?: string | null; settings?: DeepPartial; cacheDirOverride?: string | null; welcomeAcknowledged?: boolean;