diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index fb63aff..428aa14 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -6,6 +6,10 @@ on: - cron: "0 16 * * *" workflow_dispatch: +concurrency: + group: dev-nightly-fixed-release + cancel-in-progress: true + permissions: contents: write @@ -56,7 +60,23 @@ jobs: fi gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH" RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')" - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null + RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after create:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi dev-mac-arm64: needs: prepare @@ -77,6 +97,22 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure mac key helpers are executable + shell: bash + run: | + set -euo pipefail + for file in \ + resources/key/macos/universal/xkey_helper \ + resources/key/macos/universal/image_scan_helper \ + resources/key/macos/universal/xkey_helper_macos \ + resources/key/macos/universal/libwx_key.dylib + do + if [ -f "$file" ]; then + chmod +x "$file" + ls -l "$file" + fi + done + - name: Set dev version shell: bash run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version @@ -266,21 +302,25 @@ jobs: - name: Update fixed dev release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }} shell: bash run: | set -euo pipefail - TAG="$FIXED_DEV_TAG" + TAG="${FIXED_DEV_TAG:-}" + if [ -z "$TAG" ]; then + echo "FIXED_DEV_TAG is empty, abort." + exit 1 + fi REPO="$GITHUB_REPOSITORY" RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" + echo "Using release tag: $TAG" - if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then echo "Release $TAG not found, skip notes update." exit 0 fi - ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" + ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")" pick_asset() { local pattern="$1" @@ -329,9 +369,39 @@ jobs: - 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态 EOF + update_release_notes() { + local attempts=5 + local delay_seconds=2 + local i + for ((i=1; i<=attempts; i++)); do + if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then + return 0 + fi + if [ "$i" -lt "$attempts" ]; then + echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..." + sleep "$delay_seconds" + fi + done + return 1 + } + + update_release_notes RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" - jq -n --rawfile body dev_release_notes.md \ - '{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \ - > release_update_payload.json - gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null - gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url + RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after notes update:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi + gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 751d227..52aa2d4 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -6,6 +6,10 @@ on: - cron: "0 16 * * *" workflow_dispatch: +concurrency: + group: preview-nightly-fixed-release + cancel-in-progress: true + permissions: contents: write @@ -82,7 +86,23 @@ jobs: fi gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH" RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')" - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null + RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after create:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi preview-mac-arm64: needs: prepare @@ -104,6 +124,22 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure mac key helpers are executable + shell: bash + run: | + set -euo pipefail + for file in \ + resources/key/macos/universal/xkey_helper \ + resources/key/macos/universal/image_scan_helper \ + resources/key/macos/universal/xkey_helper_macos \ + resources/key/macos/universal/libwx_key.dylib + do + if [ -f "$file" ]; then + chmod +x "$file" + ls -l "$file" + fi + done + - name: Set preview version shell: bash run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version @@ -311,17 +347,22 @@ jobs: run: | set -euo pipefail - TAG="$FIXED_PREVIEW_TAG" + TAG="${FIXED_PREVIEW_TAG:-}" + if [ -z "$TAG" ]; then + echo "FIXED_PREVIEW_TAG is empty, abort." + exit 1 + fi CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}" REPO="$GITHUB_REPOSITORY" RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" + echo "Using release tag: $TAG" - if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then echo "Release $TAG not found (possibly all publish jobs failed), skip notes update." exit 0 fi - ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" + ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")" pick_asset() { local pattern="$1" @@ -371,9 +412,39 @@ jobs: > 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源 EOF + update_release_notes() { + local attempts=5 + local delay_seconds=2 + local i + for ((i=1; i<=attempts; i++)); do + if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then + return 0 + fi + if [ "$i" -lt "$attempts" ]; then + echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..." + sleep "$delay_seconds" + fi + done + return 1 + } + + update_release_notes RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" - jq -n --rawfile body preview_release_notes.md \ - '{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \ - > release_update_payload.json - gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null - gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url + RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after notes update:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi + gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc26d08..44cf1bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,12 +31,28 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure mac key helpers are executable + shell: bash + run: | + set -euo pipefail + for file in \ + resources/key/macos/universal/xkey_helper \ + resources/key/macos/universal/image_scan_helper \ + resources/key/macos/universal/xkey_helper_macos \ + resources/key/macos/universal/libwx_key.dylib + do + if [ -f "$file" ]; then + chmod +x "$file" + ls -l "$file" + fi + done + - name: Sync version with tag shell: bash run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" + npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check shell: bash @@ -93,7 +109,7 @@ jobs: run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" + npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check shell: bash @@ -115,7 +131,7 @@ jobs: TAG=${GITHUB_REF_NAME} REPO=${{ github.repository }} MINIMUM_VERSION="4.1.7" - gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null || true + gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber @@ -144,7 +160,7 @@ jobs: run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" + npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check shell: bash @@ -166,7 +182,7 @@ jobs: TAG=${GITHUB_REF_NAME} REPO=${{ github.repository }} MINIMUM_VERSION="4.1.7" - gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null || true + gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber @@ -195,7 +211,7 @@ jobs: run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" + npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check shell: bash @@ -217,7 +233,7 @@ jobs: TAG=${GITHUB_REF_NAME} REPO=${{ github.repository }} MINIMUM_VERSION="4.1.7" - gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null || true + gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index e598698..11a1376 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -1,5 +1,8 @@ name: Security Scan +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + on: schedule: - cron: '0 2 * * *' # 每天 UTC 02:00 @@ -24,15 +27,15 @@ jobs: steps: - name: Checkout ${{ matrix.branch }} - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ matrix.branch }} fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '24' cache: 'npm' # 使用 npm 缓存加速 - name: Install dependencies @@ -71,10 +74,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '24' + cache: 'npm' + - name: Run npm audit on all branches run: | git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do @@ -84,4 +93,4 @@ jobs: npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true npm audit --audit-level=moderate 2>/dev/null || true done - continue-on-error: true \ No newline at end of file + continue-on-error: true diff --git a/.gitignore b/.gitignore index ae6f9bf..920d437 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,8 @@ Thumbs.db *.aps wcdb/ +!resources/wcdb/ +!resources/wcdb/** xkey/ server/ *info @@ -73,4 +75,4 @@ pnpm-lock.yaml wechat-research-site .codex weflow-web-offical -Insight \ No newline at end of file +Insight diff --git a/README.md b/README.md index 01e7beb..6a97826 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,23 @@ # WeFlow -WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告 - ---- +WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。

- WeFlow + WeFlow 应用预览

---- -

- -Stargazers - - -Forks - - -Issues - - -Downloads - - -Telegram - + + Stargazers + Forks + Issues + Downloads +

+ + Telegram Channel + Star History Rank

- > [!TIP] > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) @@ -47,14 +36,12 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 ## 支持平台与设备 - | 平台 | 设备/架构 | 安装包 | |------|----------|--------| | Windows | Windows10+、x64(amd64) | `.exe` | | macOS | Apple Silicon(M 系列,arm64) | `.dmg` | | Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` | - ## 快速开始 若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。 @@ -93,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可 完整接口文档:[点击查看](docs/HTTP-API.md) - ## 面向开发者 如果你想从源码构建或为项目贡献代码,请遵循以下步骤: @@ -108,9 +94,24 @@ npm install # 3. 运行应用(开发模式) npm run dev - ``` +## 构建状态 + +用于开发者排查发布链路,普通用户可忽略: + +

+ + Release Workflow + + + Preview Nightly Workflow + + + Dev Daily Workflow + +

+ ## 致谢 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 @@ -120,18 +121,16 @@ npm run dev 如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡: - -> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6` - +> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6` ## Star History - - - - Star History Chart - + + + + Star History Chart +
diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts index 429a00f..6107dd2 100644 --- a/electron/imageSearchWorker.ts +++ b/electron/imageSearchWorker.ts @@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean { function stripDatVariantSuffix(base: string): string { const lower = base.toLowerCase() - const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] + const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c'] for (const suffix of suffixes) { if (lower.endsWith(suffix)) { return lower.slice(0, -suffix.length) @@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number { const lower = fileName.toLowerCase() const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 + if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550 + if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520 + if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510 if (!hasXVariant(baseLower)) return 500 - if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 if (isThumbnailDat(lower)) return 100 return 350 diff --git a/electron/main.ts b/electron/main.ts index 389e168..48d18de 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -182,7 +182,6 @@ const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => { autoUpdater.channel = nextUpdaterChannel lastAppliedUpdaterChannel = nextUpdaterChannel lastAppliedUpdaterFeedUrl = nextFeedUrl - console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},feed=${nextFeedUrl},allowDowngrade=${autoUpdater.allowDowngrade}`) } applyAutoUpdateChannel('startup') @@ -1619,6 +1618,7 @@ function registerIpcHandlers() { applyAutoUpdateChannel('settings') } void messagePushService.handleConfigChanged(key) + void insightService.handleConfigChanged(key) return result }) @@ -1644,6 +1644,7 @@ function registerIpcHandlers() { } configService?.clear() messagePushService.handleConfigCleared() + insightService.handleConfigCleared() return true }) @@ -1692,13 +1693,6 @@ function registerIpcHandlers() { return applyLaunchAtStartupPreference(enabled === true) }) - ipcMain.handle('app:checkWayland', async () => { - if (process.platform !== 'linux') return false; - - const sessionType = process.env.XDG_SESSION_TYPE?.toLowerCase(); - return Boolean(process.env.WAYLAND_DISPLAY || sessionType === 'wayland'); - }) - ipcMain.handle('log:getPath', async () => { return join(app.getPath('userData'), 'logs', 'wcdb.log') }) @@ -2572,7 +2566,13 @@ function registerIpcHandlers() { ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => { return imageDecryptService.decryptImage(payload) }) - ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => { + ipcMain.handle('image:resolveCache', async (_, payload: { + sessionId?: string + imageMd5?: string + imageDatName?: string + disableUpdateCheck?: boolean + allowCacheIndex?: boolean + }) => { return imageDecryptService.resolveCachedImage(payload) }) ipcMain.handle( @@ -2580,13 +2580,14 @@ function registerIpcHandlers() { async ( _, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, - options?: { disableUpdateCheck?: boolean } + options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean } ) => { const list = Array.isArray(payloads) ? payloads : [] const rows = await Promise.all(list.map(async (payload) => { return imageDecryptService.resolveCachedImage({ ...payload, - disableUpdateCheck: options?.disableUpdateCheck === true + disableUpdateCheck: options?.disableUpdateCheck === true, + allowCacheIndex: options?.allowCacheIndex !== false }) })) return { success: true, rows } @@ -2597,7 +2598,7 @@ function registerIpcHandlers() { async ( _, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, - options?: { allowDecrypt?: boolean } + options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } ) => { imagePreloadService.enqueue(payloads || [], options) return true @@ -3454,12 +3455,38 @@ app.whenReady().then(async () => { } const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const withTimeout = (task: () => Promise, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => { + return new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => { + if (settled) return + settled = true + resolve({ timedOut: true, error: `timeout(${timeoutMs}ms)` }) + }, timeoutMs) + + task() + .then((value) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ timedOut: false, value }) + }) + .catch((error) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ timedOut: false, error: String(error) }) + }) + }) + } // 初始化配置服务 updateSplashProgress(5, '正在加载配置...') configService = new ConfigService() applyAutoUpdateChannel('startup') syncLaunchAtStartupPreference() + const onboardingDone = configService.get('onboardingDone') === true + shouldShowMain = onboardingDone // 将用户主题配置推送给 Splash 窗口 if (splashWindow && !splashWindow.isDestroyed()) { @@ -3472,7 +3499,7 @@ app.whenReady().then(async () => { await delay(200) // 设置资源路径 - updateSplashProgress(10, '正在初始化...') + updateSplashProgress(12, '正在初始化...') const candidateResources = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') @@ -3482,13 +3509,13 @@ app.whenReady().then(async () => { await delay(200) // 初始化数据库服务 - updateSplashProgress(18, '正在初始化...') + updateSplashProgress(20, '正在初始化...') wcdbService.setPaths(resourcesPath, userDataPath) wcdbService.setLogEnabled(configService.get('logEnabled') === true) await delay(200) // 注册 IPC 处理器 - updateSplashProgress(25, '正在初始化...') + updateSplashProgress(28, '正在初始化...') registerIpcHandlers() chatService.addDbMonitorListener((type, json) => { messagePushService.handleDbMonitorChange(type, json) @@ -3498,12 +3525,54 @@ app.whenReady().then(async () => { insightService.start() await delay(200) - // 检查配置状态 - const onboardingDone = configService.get('onboardingDone') - shouldShowMain = onboardingDone === true + // 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等) + if (onboardingDone) { + updateSplashProgress(34, '正在连接数据库...') + const connectWarmup = await withTimeout(() => chatService.connect(), 12000) + const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true + + if (!connected) { + const reason = connectWarmup.timedOut + ? connectWarmup.error + : (connectWarmup.value?.error || connectWarmup.error || 'unknown') + console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason) + updateSplashProgress(68, '数据库预热已跳过') + } else { + const preloadUsernames = new Set() + + updateSplashProgress(44, '正在预加载会话...') + const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000) + if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) { + for (const session of sessionsWarmup.value.sessions) { + const username = String((session as any)?.username || '').trim() + if (username) preloadUsernames.add(username) + } + } + + updateSplashProgress(56, '正在预加载联系人...') + const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000) + if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) { + for (const contact of contactsWarmup.value.contacts) { + const username = String((contact as any)?.username || '').trim() + if (username) preloadUsernames.add(username) + } + } + + updateSplashProgress(63, '正在缓存联系人头像...') + const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000) + if (avatarWarmupUsernames.length > 0) { + await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000) + } + + updateSplashProgress(68, '正在初始化消息库索引...') + await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000) + } + } else { + updateSplashProgress(68, '首次启动准备中...') + } // 创建主窗口(不显示,由启动流程统一控制) - updateSplashProgress(30, '正在加载界面...') + updateSplashProgress(70, '正在准备主窗口...') mainWindow = createWindow({ autoShow: false }) let iconName = 'icon.ico'; @@ -3575,7 +3644,7 @@ app.whenReady().then(async () => { ) // 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点) - updateSplashProgress(30, '正在加载界面...', true) + updateSplashProgress(70, '正在准备主窗口...', true) await new Promise((resolve) => { if (mainWindowReady) { resolve() diff --git a/electron/preload.ts b/electron/preload.ts index c68a39b..48564f1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', { onShow: (callback: (event: any, data: any) => void) => { ipcRenderer.on('notification:show', callback) return () => ipcRenderer.removeAllListeners('notification:show') + }, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话 + onNavigateToSession: (callback: (sessionId: string) => void) => { + const listener = (_: any, sessionId: string) => callback(sessionId) + ipcRenderer.on('navigate-to-session', listener) + return () => ipcRenderer.removeListener('navigate-to-session', listener) } }, @@ -66,7 +71,6 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('app:updateAvailable', (_, info) => callback(info)) return () => ipcRenderer.removeAllListeners('app:updateAvailable') }, - checkWayland: () => ipcRenderer.invoke('app:checkWayland'), }, // 日志 @@ -266,15 +270,21 @@ contextBridge.exposeInMainWorld('electronAPI', { image: { decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => ipcRenderer.invoke('image:decrypt', payload), - resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => + resolveCache: (payload: { + sessionId?: string + imageMd5?: string + imageDatName?: string + disableUpdateCheck?: boolean + allowCacheIndex?: boolean + }) => ipcRenderer.invoke('image:resolveCache', payload), resolveCacheBatch: ( payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, - options?: { disableUpdateCheck?: boolean } + options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean } ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), preload: ( payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, - options?: { allowDecrypt?: boolean } + options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean } ) => ipcRenderer.invoke('image:preload', payloads, options), onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) diff --git a/electron/services/avatarFileCacheService.ts b/electron/services/avatarFileCacheService.ts new file mode 100644 index 0000000..7216154 --- /dev/null +++ b/electron/services/avatarFileCacheService.ts @@ -0,0 +1,219 @@ +import https from "https"; +import http, { IncomingMessage } from "http"; +import { promises as fs } from "fs"; +import { join } from "path"; +import { ConfigService } from "./config"; + +// 头像文件缓存服务 - 复用项目已有的缓存目录结构 +export class AvatarFileCacheService { + private static instance: AvatarFileCacheService | null = null; + + // 头像文件缓存目录 + private readonly cacheDir: string; + // 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的) + private readonly pendingDownloads: Map> = + new Map(); + // LRU 追踪:文件路径->最后访问时间 + private readonly lruOrder: string[] = []; + private readonly maxCacheFiles = 100; + + private constructor() { + const basePath = ConfigService.getInstance().getCacheBasePath(); + this.cacheDir = join(basePath, "avatar-files"); + this.ensureCacheDir(); + this.loadLruOrder(); + } + + public static getInstance(): AvatarFileCacheService { + if (!AvatarFileCacheService.instance) { + AvatarFileCacheService.instance = new AvatarFileCacheService(); + } + return AvatarFileCacheService.instance; + } + + private ensureCacheDir(): void { + // 同步确保目录存在(构造函数调用) + try { + fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {}); + } catch {} + } + + private async ensureCacheDirAsync(): Promise { + try { + await fs.mkdir(this.cacheDir, { recursive: true }); + } catch {} + } + + private getFilePath(url: string): string { + // 使用URL的hash作为文件名,避免特殊字符问题 + const hash = this.hashString(url); + return join(this.cacheDir, `avatar_${hash}.png`); + } + + private hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // 转换为32位整数 + } + return Math.abs(hash).toString(16); + } + + private async loadLruOrder(): Promise { + try { + const entries = await fs.readdir(this.cacheDir); + // 按修改时间排序(旧的在前) + const filesWithTime: { file: string; mtime: number }[] = []; + for (const entry of entries) { + if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue; + try { + const stat = await fs.stat(join(this.cacheDir, entry)); + filesWithTime.push({ file: entry, mtime: stat.mtimeMs }); + } catch {} + } + filesWithTime.sort((a, b) => a.mtime - b.mtime); + this.lruOrder.length = 0; + this.lruOrder.push(...filesWithTime.map((f) => f.file)); + } catch {} + } + + private updateLru(fileName: string): void { + const index = this.lruOrder.indexOf(fileName); + if (index > -1) { + this.lruOrder.splice(index, 1); + } + this.lruOrder.push(fileName); + } + + private async evictIfNeeded(): Promise { + while (this.lruOrder.length >= this.maxCacheFiles) { + const oldest = this.lruOrder.shift(); + if (oldest) { + try { + await fs.rm(join(this.cacheDir, oldest)); + console.log(`[AvatarFileCache] Evicted: ${oldest}`); + } catch {} + } + } + } + + private async downloadAvatar(url: string): Promise { + const localPath = this.getFilePath(url); + + // 检查文件是否已存在 + try { + await fs.access(localPath); + const fileName = localPath.split("/").pop()!; + this.updateLru(fileName); + return localPath; + } catch {} + + await this.ensureCacheDirAsync(); + await this.evictIfNeeded(); + + return new Promise((resolve) => { + const options = { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", + Referer: "https://servicewechat.com/", + Accept: + "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Accept-Language": "zh-CN,zh;q=0.9", + Connection: "keep-alive", + }, + }; + + const callback = (res: IncomingMessage) => { + if (res.statusCode !== 200) { + resolve(null); + return; + } + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", async () => { + try { + const buffer = Buffer.concat(chunks); + await fs.writeFile(localPath, buffer); + const fileName = localPath.split("/").pop()!; + this.updateLru(fileName); + console.log( + `[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`, + ); + resolve(localPath); + } catch { + resolve(null); + } + }); + res.on("error", () => resolve(null)); + }; + + const req = url.startsWith("https") + ? https.get(url, options, callback) + : http.get(url, options, callback); + + req.on("error", () => resolve(null)); + req.setTimeout(10000, () => { + req.destroy(); + resolve(null); + }); + }); + } + + /** + * 获取头像本地文件路径,如果需要会下载 + * 同一URL并发调用会复用同一个下载任务 + */ + async getAvatarPath(url: string): Promise { + if (!url) return null; + + // 检查是否有正在进行的下载 + const pending = this.pendingDownloads.get(url); + if (pending) { + return pending; + } + + // 发起新下载 + const downloadPromise = this.downloadAvatar(url); + this.pendingDownloads.set(url, downloadPromise); + + try { + const result = await downloadPromise; + return result; + } finally { + this.pendingDownloads.delete(url); + } + } + + // 清理所有缓存文件(App退出时调用) + async clearCache(): Promise { + try { + const entries = await fs.readdir(this.cacheDir); + for (const entry of entries) { + if (entry.startsWith("avatar_") && entry.endsWith(".png")) { + try { + await fs.rm(join(this.cacheDir, entry)); + } catch {} + } + } + this.lruOrder.length = 0; + console.log("[AvatarFileCache] Cache cleared"); + } catch {} + } + + // 获取当前缓存的文件数量 + async getCacheCount(): Promise { + try { + const entries = await fs.readdir(this.cacheDir); + return entries.filter( + (e) => e.startsWith("avatar_") && e.endsWith(".png"), + ).length; + } catch { + return 0; + } + } +} + +export const avatarFileCache = AvatarFileCacheService.getInstance(); diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 9cf81b6..24da2ca 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -323,6 +323,8 @@ class ChatService { private contactLabelNameMapCacheAt = 0 private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000 private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null + private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>() + private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000 private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN') private readonly slowGetContactsLogThresholdMs = 1200 @@ -513,6 +515,43 @@ class ChatService { } } + async warmupMessageDbSnapshot(): Promise<{ success: boolean; messageDbCount?: number; mediaDbCount?: number; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const [messageSnapshot, mediaResult] = await Promise.all([ + this.getMessageDbCountSnapshot(true), + wcdbService.listMediaDbs() + ]) + + let messageDbCount = 0 + if (messageSnapshot.success && Array.isArray(messageSnapshot.dbPaths)) { + messageDbCount = messageSnapshot.dbPaths.length + } + + let mediaDbCount = 0 + if (mediaResult.success && Array.isArray(mediaResult.data)) { + this.mediaDbsCache = [...mediaResult.data] + this.mediaDbsCacheTime = Date.now() + mediaDbCount = mediaResult.data.length + } + + if (!messageSnapshot.success && !mediaResult.success) { + return { + success: false, + error: messageSnapshot.error || mediaResult.error || '初始化消息库索引失败' + } + } + + return { success: true, messageDbCount, mediaDbCount } + } catch (e) { + return { success: false, error: String(e) } + } + } + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { if (this.connected && wcdbService.isReady()) { return { success: true } @@ -1362,8 +1401,50 @@ class ChatService { } } + private getContactsCacheScope(): string { + const dbPath = String(this.configService.get('dbPath') || '').trim() + const myWxid = String(this.configService.get('myWxid') || '').trim() + return `${dbPath}::${myWxid}` + } + + private cloneContacts(contacts: ContactInfo[]): ContactInfo[] { + return (contacts || []).map((contact) => ({ + ...contact, + labels: Array.isArray(contact.labels) ? [...contact.labels] : contact.labels + })) + } + + private getContactsFromMemoryCache(mode: 'lite' | 'full', scope: string): ContactInfo[] | null { + const cached = this.contactsMemoryCache.get(mode) + if (!cached) return null + if (cached.scope !== scope) return null + if (Date.now() - cached.updatedAt > this.contactsMemoryCacheTtlMs) return null + return this.cloneContacts(cached.contacts) + } + + private setContactsMemoryCache(mode: 'lite' | 'full', scope: string, contacts: ContactInfo[]): void { + this.contactsMemoryCache.set(mode, { + scope, + updatedAt: Date.now(), + contacts: this.cloneContacts(contacts) + }) + } + private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> { const isLiteMode = options?.lite === true + const mode: 'lite' | 'full' = isLiteMode ? 'lite' : 'full' + const cacheScope = this.getContactsCacheScope() + const cachedContacts = this.getContactsFromMemoryCache(mode, cacheScope) + if (cachedContacts) { + return { success: true, contacts: cachedContacts } + } + if (isLiteMode) { + const fullCachedContacts = this.getContactsFromMemoryCache('full', cacheScope) + if (fullCachedContacts) { + return { success: true, contacts: fullCachedContacts } + } + } + const startedAt = Date.now() const stageDurations: Array<{ stage: string; ms: number }> = [] const captureStage = (stage: string, stageStartedAt: number) => { @@ -1487,6 +1568,10 @@ class ChatService { .join(', ') console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`) } + this.setContactsMemoryCache(mode, cacheScope, result) + if (!isLiteMode) { + this.setContactsMemoryCache('lite', cacheScope, result) + } return { success: true, contacts: result } } catch (e) { console.error('ChatService: 获取通讯录失败:', e) @@ -2886,6 +2971,7 @@ class ChatService { this.sessionTablesCache.clear() this.messageTableColumnsCache.clear() this.messageDbCountSnapshotCache = null + this.contactsMemoryCache.clear() this.refreshSessionStatsCacheScope(scope) this.refreshGroupMyMessageCountCacheScope(scope) } @@ -5983,6 +6069,7 @@ class ChatService { if (includeContacts) { this.avatarCache.clear() this.contactCacheService.clear() + this.contactsMemoryCache.clear() } if (includeMessages) { diff --git a/electron/services/config.ts b/electron/services/config.ts index 9f37183..c096d06 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -270,7 +270,9 @@ export class ConfigService { const inLockMode = this.isLockMode() && this.unlockPassword if (ENCRYPTED_BOOL_KEYS.has(key)) { - toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + const boolValue = value === true || value === 'true' + // `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗 + toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K] } else if (ENCRYPTED_NUMBER_KEYS.has(key)) { if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) { toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K] @@ -649,7 +651,7 @@ export class ConfigService { clearHelloSecret(): void { this.store.set('authHelloSecret', '' as any) - this.store.set('authUseHello', this.safeEncrypt('false') as any) + this.store.set('authUseHello', false as any) } // === 迁移 === @@ -658,13 +660,18 @@ export class ConfigService { // 将旧版明文 auth 字段迁移为 safeStorage 加密格式 // 如果已经是 safe: 或 lock: 前缀则跳过 const rawEnabled: any = this.store.get('authEnabled') - if (typeof rawEnabled === 'boolean') { - this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any) + if (rawEnabled === true || rawEnabled === 'true') { + this.store.set('authEnabled', this.safeEncrypt('true') as any) + } else if (rawEnabled === false || rawEnabled === 'false') { + // 保持 false 为明文布尔,避免冷启动访问 keychain + this.store.set('authEnabled', false as any) } const rawUseHello: any = this.store.get('authUseHello') - if (typeof rawUseHello === 'boolean') { - this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any) + if (rawUseHello === true || rawUseHello === 'true') { + this.store.set('authUseHello', this.safeEncrypt('true') as any) + } else if (rawUseHello === false || rawUseHello === 'false') { + this.store.set('authUseHello', false as any) } const rawPassword: any = this.store.get('authPassword') diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 2512f72..d13458c 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -92,6 +92,7 @@ export interface ExportOptions { dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string + fileNamingMode?: 'classic' | 'date-range' exportMedia?: boolean exportAvatars?: boolean exportImages?: boolean @@ -494,6 +495,80 @@ class ExportService { } } + private sanitizeExportFileNamePart(value: string): string { + return String(value || '') + .replace(/[<>:"\/\\|?*]/g, '_') + .replace(/\.+$/, '') + .trim() + } + + private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' { + return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic' + } + + private formatDateTokenBySeconds(seconds?: number): string | null { + if (!Number.isFinite(seconds) || (seconds || 0) <= 0) return null + const date = new Date(Math.floor(Number(seconds)) * 1000) + if (Number.isNaN(date.getTime())) return null + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}${m}${d}` + } + + private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string { + const start = this.formatDateTokenBySeconds(dateRange?.start) + const end = this.formatDateTokenBySeconds(dateRange?.end) + if (start && end) { + if (start === end) return start + return start < end ? `${start}-${end}` : `${end}-${start}` + } + if (start) return `${start}-至今` + if (end) return `截至-${end}` + return '全部时间' + } + + private buildSessionExportBaseName( + sessionId: string, + displayName: string, + options: ExportOptions + ): string { + const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session' + const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '') + const namingMode = this.normalizeFileNamingMode(options.fileNamingMode) + const parts = [baseName] + if (suffix) parts.push(suffix) + if (namingMode === 'date-range') { + parts.push(this.buildDateRangeFileNamePart(options.dateRange)) + } + return this.sanitizeExportFileNamePart(parts.join('_')) || 'session' + } + + private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set): Promise { + const dir = path.dirname(preferredPath) + const ext = path.extname(preferredPath) + const base = path.basename(preferredPath, ext) + + for (let attempt = 0; attempt < 10000; attempt += 1) { + const candidate = attempt === 0 + ? preferredPath + : path.join(dir, `${base}_${attempt + 1}${ext}`) + + if (reservedPaths.has(candidate)) continue + + const exists = await this.pathExists(candidate) + if (reservedPaths.has(candidate)) continue + if (exists) continue + + reservedPaths.add(candidate) + return candidate + } + + const fallback = path.join(dir, `${base}_${Date.now()}${ext}`) + reservedPaths.add(fallback) + return fallback + } + private isCloneUnsupportedError(code: string | undefined): boolean { return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY' } @@ -8911,6 +8986,7 @@ class ExportService { ? path.join(outputDir, 'texts') : outputDir const createdTaskDirs = new Set() + const reservedOutputPaths = new Set() const ensureTaskDir = async (dirPath: string) => { if (createdTaskDirs.has(dirPath)) return await fs.promises.mkdir(dirPath, { recursive: true }) @@ -9159,10 +9235,8 @@ class ExportService { phaseLabel: '准备导出' }) - const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() - const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' - const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '') - const safeName = suffix ? `${baseName}_${suffix}` : baseName + const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode) + const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions) const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : '' const fileNameWithPrefix = `${sessionTypePrefix}${safeName}` @@ -9180,13 +9254,13 @@ class ExportService { else if (effectiveOptions.format === 'txt') ext = '.txt' else if (effectiveOptions.format === 'weclone') ext = '.csv' else if (effectiveOptions.format === 'html') ext = '.html' - const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) + const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) const canTrySkipUnchanged = canTrySkipUnchangedTextSessions && typeof messageCountHint === 'number' && messageCountHint >= 0 && typeof latestTimestampHint === 'number' && latestTimestampHint > 0 && - await this.pathExists(outputPath) + await this.pathExists(preferredOutputPath) if (canTrySkipUnchanged) { const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format) const hasNoDataChange = Boolean( @@ -9213,6 +9287,10 @@ class ExportService { } } + const outputPath = fileNamingMode === 'date-range' + ? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths) + : preferredOutputPath + let result: { success: boolean; error?: string } if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') { result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index b0d8513..84c908c 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -63,6 +63,7 @@ type CachedImagePayload = { imageDatName?: string preferFilePath?: boolean disableUpdateCheck?: boolean + allowCacheIndex?: boolean } type DecryptImagePayload = CachedImagePayload & { @@ -116,7 +117,9 @@ export class ImageDecryptService { } async resolveCachedImage(payload: CachedImagePayload): Promise { - await this.ensureCacheIndexed() + if (payload.allowCacheIndex !== false) { + await this.ensureCacheIndexed() + } const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { @@ -673,41 +676,53 @@ export class ImageDecryptService { return null } - // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) - if (!allowThumbnail) { - return null - } + const searchNames = Array.from( + new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean)) + ) + if (searchNames.length === 0) return null - if (!imageDatName) return null if (!skipResolvedCache) { - const cached = this.resolvedCache.get(imageDatName) - if (cached && existsSync(cached)) { - const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) - if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred - // 缓存的是缩略图,尝试找高清图 - const hdPath = this.findHdVariantInSameDir(preferred) - if (hdPath) return hdPath + for (const searchName of searchNames) { + const cached = this.resolvedCache.get(searchName) + if (cached && existsSync(cached)) { + const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) + if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred + // 缓存的是缩略图,尝试找高清图 + const hdPath = this.findHdVariantInSameDir(preferred) + if (hdPath) return hdPath + } } } - const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail) - if (datPath) { - this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath }) - this.resolvedCache.set(imageDatName, datPath) - this.cacheDatPath(accountDir, imageDatName, datPath) - return datPath - } - const normalized = this.normalizeDatBase(imageDatName) - if (normalized !== imageDatName.toLowerCase()) { - const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) - if (normalizedPath) { - this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath }) - this.resolvedCache.set(imageDatName, normalizedPath) - this.cacheDatPath(accountDir, imageDatName, normalizedPath) - return normalizedPath + for (const searchName of searchNames) { + const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail) + if (datPath) { + this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath }) + if (imageDatName) this.resolvedCache.set(imageDatName, datPath) + if (imageMd5) this.resolvedCache.set(imageMd5, datPath) + this.cacheDatPath(accountDir, searchName, datPath) + if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath) + if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath) + return datPath } } - this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized }) + + for (const searchName of searchNames) { + const normalized = this.normalizeDatBase(searchName) + if (normalized !== searchName.toLowerCase()) { + const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) + if (normalizedPath) { + this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath }) + if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath) + if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath) + this.cacheDatPath(accountDir, searchName, normalizedPath) + if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath) + if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath) + return normalizedPath + } + } + } + this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames }) return null } @@ -1042,7 +1057,7 @@ export class ImageDecryptService { private stripDatVariantSuffix(base: string): string { const lower = base.toLowerCase() - const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c'] + const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c'] for (const suffix of suffixes) { if (lower.endsWith(suffix)) { return lower.slice(0, -suffix.length) @@ -1058,8 +1073,10 @@ export class ImageDecryptService { const lower = name.toLowerCase() const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 + if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550 + if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520 + if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510 if (!this.hasXVariant(baseLower)) return 500 - if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450 if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 if (this.isThumbnailDat(lower)) return 100 return 350 @@ -1070,9 +1087,13 @@ export class ImageDecryptService { const names = [ `${baseName}_h.dat`, `${baseName}.h.dat`, - `${baseName}.dat`, `${baseName}_hd.dat`, `${baseName}.hd.dat`, + `${baseName}_b.dat`, + `${baseName}.b.dat`, + `${baseName}_w.dat`, + `${baseName}.w.dat`, + `${baseName}.dat`, `${baseName}_c.dat`, `${baseName}.c.dat` ] diff --git a/electron/services/imagePreloadService.ts b/electron/services/imagePreloadService.ts index 2916bfe..05a772a 100644 --- a/electron/services/imagePreloadService.ts +++ b/electron/services/imagePreloadService.ts @@ -8,11 +8,13 @@ type PreloadImagePayload = { type PreloadOptions = { allowDecrypt?: boolean + allowCacheIndex?: boolean } type PreloadTask = PreloadImagePayload & { key: string allowDecrypt: boolean + allowCacheIndex: boolean } export class ImagePreloadService { @@ -27,6 +29,7 @@ export class ImagePreloadService { enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void { if (!Array.isArray(payloads) || payloads.length === 0) return const allowDecrypt = options?.allowDecrypt !== false + const allowCacheIndex = options?.allowCacheIndex !== false for (const payload of payloads) { if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break const cacheKey = payload.imageMd5 || payload.imageDatName @@ -34,7 +37,7 @@ export class ImagePreloadService { const key = `${payload.sessionId || 'unknown'}|${cacheKey}` if (this.pending.has(key)) continue this.pending.add(key) - this.queue.push({ ...payload, key, allowDecrypt }) + this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex }) } this.processQueue() } @@ -71,7 +74,8 @@ export class ImagePreloadService { sessionId: task.sessionId, imageMd5: task.imageMd5, imageDatName: task.imageDatName, - disableUpdateCheck: !task.allowDecrypt + disableUpdateCheck: !task.allowDecrypt, + allowCacheIndex: task.allowCacheIndex }) if (cached.success) return if (!task.allowDecrypt) return diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index e03d657..47295ad 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -15,10 +15,8 @@ import https from 'https' import http from 'http' -import fs from 'fs' -import path from 'path' import { URL } from 'url' -import { app, Notification } from 'electron' +import { Notification } from 'electron' import { ConfigService } from './config' import { chatService, ChatSession, Message } from './chatService' @@ -38,6 +36,13 @@ const API_TIMEOUT_MS = 45_000 /** 沉默天数阈值默认值 */ const DEFAULT_SILENCE_DAYS = 3 +const INSIGHT_CONFIG_KEYS = new Set([ + 'aiInsightEnabled', + 'aiInsightScanIntervalHours', + 'dbPath', + 'decryptKey', + 'myWxid' +]) // ─── 类型 ──────────────────────────────────────────────────────────────────── @@ -46,33 +51,17 @@ interface TodayTriggerRecord { timestamps: number[] } -// ─── 桌面日志 ───────────────────────────────────────────────────────────────── +// ─── 日志 ───────────────────────────────────────────────────────────────────── /** - * 将日志同时输出到 console 和桌面上的 weflow-insight.log 文件。 - * 文件名带当天日期,每天自动换一个新文件,旧文件保留。 + * 仅输出到 console,不落盘到文件。 */ function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void { - const now = new Date() - const dateStr = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-') - const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false }) - const line = `[${dateStr} ${timeStr}] [${level}] ${message}\n` - - // 同步到 console if (level === 'ERROR' || level === 'WARN') { console.warn(`[InsightService] ${message}`) } else { console.log(`[InsightService] ${message}`) } - - // 异步写入桌面日志文件,避免同步磁盘 I/O 阻塞 Electron 主线程事件循环 - try { - const desktopPath = app.getPath('desktop') - const logFile = path.join(desktopPath, `weflow-insight-${dateStr}.log`) - fs.appendFile(logFile, line, 'utf-8', () => { /* 失败静默处理 */ }) - } catch { - // getPath 失败时静默处理 - } } // ─── 工具函数 ───────────────────────────────────────────────────────────────── @@ -234,15 +223,64 @@ class InsightService { start(): void { if (this.started) return this.started = true - insightLog('INFO', '已启动') - this.scheduleSilenceScan() + void this.refreshConfiguration('startup') } stop(): void { + const hadActiveFlow = + this.dbDebounceTimer !== null || + this.silenceScanTimer !== null || + this.silenceInitialDelayTimer !== null || + this.processing this.started = false + this.clearTimers() + this.clearRuntimeCache() + this.processing = false + if (hadActiveFlow) { + insightLog('INFO', '已停止') + } + } + + async handleConfigChanged(key: string): Promise { + const normalizedKey = String(key || '').trim() + if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return + + // 数据库相关配置变更后,丢弃缓存并强制下次重连 + if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { + this.clearRuntimeCache() + } + + await this.refreshConfiguration(`config:${normalizedKey}`) + } + + handleConfigCleared(): void { + this.clearTimers() + this.clearRuntimeCache() + this.processing = false + } + + private async refreshConfiguration(_reason: string): Promise { + if (!this.started) return + if (!this.isEnabled()) { + this.clearTimers() + this.clearRuntimeCache() + this.processing = false + return + } + this.scheduleSilenceScan() + } + + private clearRuntimeCache(): void { this.dbConnected = false this.sessionCache = null this.sessionCacheAt = 0 + this.lastActivityAnalysis.clear() + this.lastSeenTimestamp.clear() + this.todayTriggers.clear() + this.todayDate = getStartOfDay() + } + + private clearTimers(): void { if (this.dbDebounceTimer !== null) { clearTimeout(this.dbDebounceTimer) this.dbDebounceTimer = null @@ -255,7 +293,6 @@ class InsightService { clearTimeout(this.silenceInitialDelayTimer) this.silenceInitialDelayTimer = null } - insightLog('INFO', '已停止') } /** @@ -452,9 +489,12 @@ class InsightService { // ── 沉默联系人扫描 ────────────────────────────────────────────────────────── private scheduleSilenceScan(): void { + this.clearTimers() + if (!this.started || !this.isEnabled()) return + // 等待扫描完成后再安排下一次,避免并发堆积 const scheduleNext = () => { - if (!this.started) return + if (!this.started || !this.isEnabled()) return const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4 const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000 insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`) @@ -474,7 +514,6 @@ class InsightService { private async runSilenceScan(): Promise { if (!this.isEnabled()) { - insightLog('INFO', '沉默扫描:AI 见解未启用,跳过') return } if (this.processing) { @@ -502,6 +541,7 @@ class InsightService { let silentCount = 0 for (const session of sessions) { + if (!this.isEnabled()) return const sessionId = session.username?.trim() || '' if (!sessionId || sessionId.endsWith('@chatroom')) continue if (sessionId.toLowerCase().includes('placeholder')) continue @@ -654,6 +694,7 @@ class InsightService { }): Promise { const { sessionId, displayName, triggerReason, silentDays } = params if (!sessionId) return + if (!this.isEnabled()) return const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string const apiKey = this.config.get('aiInsightApiKey') as string @@ -747,6 +788,7 @@ class InsightService { insightLog('INFO', `模型选择跳过 ${displayName}`) return } + if (!this.isEnabled()) return const insight = result.slice(0, 120) const notifTitle = `见解 · ${displayName}` diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 4b25c88..72c827c 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -61,6 +61,7 @@ export class KeyService { private getDllPath(): string { const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' + const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' const candidates: string[] = [] if (process.env.WX_KEY_DLL_PATH) { @@ -68,11 +69,20 @@ export class KeyService { } if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll')) candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll')) candidates.push(join(process.resourcesPath, 'wx_key.dll')) } else { const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll')) + candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll')) + candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll')) candidates.push(join(cwd, 'resources', 'wx_key.dll')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll')) candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll')) } diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index 2c8aef9..85d5a36 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -25,13 +25,23 @@ export class KeyServiceLinux { private getHelperPath(): string { const isPackaged = app.isPackaged + const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' const candidates: string[] = [] if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH) if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux')) candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux')) candidates.push(join(process.resourcesPath, 'xkey_helper_linux')) } else { + candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux')) candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux')) + candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux')) + candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux')) + candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux')) candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux')) } for (const p of candidates) { diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index e7642a9..40cb2f2 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -1,6 +1,6 @@ import { app, shell } from 'electron' import { join, basename, dirname } from 'path' -import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs' import { execFile, spawn } from 'child_process' import { promisify } from 'util' import crypto from 'crypto' @@ -27,6 +27,7 @@ export class KeyServiceMac { private getHelperPath(): string { const isPackaged = app.isPackaged + const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' const candidates: string[] = [] if (process.env.WX_KEY_HELPER_PATH) { @@ -34,12 +35,21 @@ export class KeyServiceMac { } if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper')) candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper')) candidates.push(join(process.resourcesPath, 'xkey_helper')) } else { const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper')) + candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper')) + candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper')) candidates.push(join(cwd, 'resources', 'xkey_helper')) candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper')) candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper')) } @@ -52,14 +62,24 @@ export class KeyServiceMac { private getImageScanHelperPath(): string { const isPackaged = app.isPackaged + const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' const candidates: string[] = [] if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper')) candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper')) candidates.push(join(process.resourcesPath, 'image_scan_helper')) } else { const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper')) + candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper')) + candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper')) candidates.push(join(cwd, 'resources', 'image_scan_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper')) candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper')) } @@ -72,6 +92,7 @@ export class KeyServiceMac { private getDylibPath(): string { const isPackaged = app.isPackaged + const archDir = process.arch === 'arm64' ? 'arm64' : 'x64' const candidates: string[] = [] if (process.env.WX_KEY_DYLIB_PATH) { @@ -79,11 +100,20 @@ export class KeyServiceMac { } if (isPackaged) { + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib')) + candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib')) candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib')) candidates.push(join(process.resourcesPath, 'libwx_key.dylib')) } else { const cwd = process.cwd() + candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib')) + candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib')) + candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib')) candidates.push(join(cwd, 'resources', 'libwx_key.dylib')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib')) + candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib')) candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib')) } @@ -373,19 +403,71 @@ export class KeyServiceMac { return `'${String(text).replace(/'/g, `'\\''`)}'` } + private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] { + const baseDir = dirname(primaryBinaryPath) + const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib'] + const unique: string[] = [] + for (const name of names) { + const full = join(baseDir, name) + if (!existsSync(full)) continue + if (!unique.includes(full)) unique.push(full) + } + if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) { + unique.unshift(primaryBinaryPath) + } + return unique + } + + private ensureExecutableBitsBestEffort(paths: string[]): void { + for (const p of paths) { + try { + const mode = statSync(p).mode + if ((mode & 0o111) !== 0) continue + chmodSync(p, mode | 0o111) + } catch { + // ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app) + } + } + } + + private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise { + const existing = paths.filter(p => existsSync(p)) + if (existing.length === 0) return + + const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ') + const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000)) + const scriptLines = [ + `set chmodCmd to "/bin/chmod +x ${quotedPaths}"`, + `set timeoutSec to ${timeoutSec}`, + 'with timeout of timeoutSec seconds', + 'do shell script chmodCmd with administrator privileges', + 'end timeout' + ] + + await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { + timeout: timeoutMs + 10_000 + }) + } + private async getDbKeyByHelperElevated( timeoutMs: number, onStatus?: (message: string, level: number) => void ): Promise { const helperPath = this.getHelperPath() + const artifactPaths = this.collectMacKeyArtifactPaths(helperPath) + this.ensureExecutableBitsBestEffort(artifactPaths) const waitMs = Math.max(timeoutMs, 30_000) const timeoutSec = Math.ceil(waitMs / 1000) + 30 const pid = await this.getWeChatPid() + const chmodPart = artifactPaths.length > 0 + ? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}` + : '' + const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}` + const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败 // 通过 try/on error 回传详细错误,避免只看到 "Command failed" const scriptLines = [ - `set helperPath to ${JSON.stringify(helperPath)}`, - `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + `set cmd to ${JSON.stringify(privilegedCmd)}`, `set timeoutSec to ${timeoutSec}`, 'try', 'with timeout of timeoutSec seconds', @@ -721,10 +803,12 @@ export class KeyServiceMac { try { const helperPath = this.getImageScanHelperPath() const ciphertextHex = ciphertext.toString('hex') + const artifactPaths = this.collectMacKeyArtifactPaths(helperPath) + this.ensureExecutableBitsBestEffort(artifactPaths) // 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用) if (!this._needsElevation) { - const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false) + const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths) if (direct.key) return direct.key if (direct.permissionError) { console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式') @@ -735,7 +819,12 @@ export class KeyServiceMac { // 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid) if (this._needsElevation) { - const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true) + try { + await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000) + } catch (e: any) { + console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e) + } + const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths) if (elevated.key) return elevated.key } } catch (e: any) { @@ -838,12 +927,19 @@ export class KeyServiceMac { } private _spawnScanHelper( - helperPath: string, pid: number, ciphertextHex: string, elevated: boolean + helperPath: string, + pid: number, + ciphertextHex: string, + elevated: boolean, + artifactPaths: string[] = [] ): Promise<{ key: string | null; permissionError: boolean }> { return new Promise((resolve, reject) => { let child: ReturnType if (elevated) { - const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}` + const chmodPart = artifactPaths.length > 0 + ? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && ` + : '' + const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}` child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`], { stdio: ['ignore', 'pipe', 'pipe'] }) } else { diff --git a/electron/services/linuxNotificationService.ts b/electron/services/linuxNotificationService.ts index 1e4bd22..111626c 100644 --- a/electron/services/linuxNotificationService.ts +++ b/electron/services/linuxNotificationService.ts @@ -1,12 +1,5 @@ -import dbus from "dbus-native"; -import https from "https"; -import http, { IncomingMessage } from "http"; -import { promises as fs } from "fs"; -import { join } from "path"; -import { app } from "electron"; - -const BUS_NAME = "org.freedesktop.Notifications"; -const OBJECT_PATH = "/org/freedesktop/Notifications"; +import { Notification } from "electron"; +import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService"; export interface LinuxNotificationData { sessionId?: string; @@ -18,173 +11,96 @@ export interface LinuxNotificationData { type NotificationCallback = (sessionId: string) => void; -let sessionBus: dbus.DBusConnection | null = null; let notificationCallbacks: NotificationCallback[] = []; -let pendingNotifications: Map = new Map(); +let notificationCounter = 1; +const activeNotifications: Map = new Map(); +const closeTimers: Map = new Map(); -// 头像缓存:url->localFilePath -const avatarCache: Map = new Map(); -// 缓存目录 -let avatarCacheDir: string | null = null; - -async function getSessionBus(): Promise { - if (!sessionBus) { - sessionBus = dbus.sessionBus(); - - // 挂载底层socket的error事件,防止掉线即可 - sessionBus.connection.on("error", (err: Error) => { - console.error("[LinuxNotification] D-Bus connection error:", err); - sessionBus = null; // 报错清理死对象 - }); - } - return sessionBus; +function nextNotificationId(): number { + const id = notificationCounter; + notificationCounter += 1; + return id; } -// 确保缓存目录存在 -async function ensureCacheDir(): Promise { - if (!avatarCacheDir) { - avatarCacheDir = join(app.getPath("temp"), "weflow-avatars"); +function clearNotificationState(notificationId: number): void { + activeNotifications.delete(notificationId); + const timer = closeTimers.get(notificationId); + if (timer) { + clearTimeout(timer); + closeTimers.delete(notificationId); + } +} + +function triggerNotificationCallback(sessionId: string): void { + for (const callback of notificationCallbacks) { try { - await fs.mkdir(avatarCacheDir, { recursive: true }); + callback(sessionId); } catch (error) { - console.error( - "[LinuxNotification] Failed to create avatar cache dir:", - error, - ); + console.error("[LinuxNotification] Callback error:", error); } } - return avatarCacheDir; -} - -// 下载头像到本地临时文件 -async function downloadAvatarToLocal(url: string): Promise { - // 检查缓存 - if (avatarCache.has(url)) { - return avatarCache.get(url) || null; - } - - try { - const cacheDir = await ensureCacheDir(); - // 生成唯一文件名 - const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`; - const localPath = join(cacheDir, fileName); - - await new Promise((resolve, reject) => { - // 微信 CDN 需要特殊的请求头才能下载图片 - const options = { - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351", - Referer: "https://servicewechat.com/", - Accept: - "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, br", - "Accept-Language": "zh-CN,zh;q=0.9", - Connection: "keep-alive", - }, - }; - - const callback = (res: IncomingMessage) => { - if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode}`)); - return; - } - const chunks: Buffer[] = []; - res.on("data", (chunk: Buffer) => chunks.push(chunk)); - res.on("end", async () => { - try { - const buffer = Buffer.concat(chunks); - await fs.writeFile(localPath, buffer); - avatarCache.set(url, localPath); - resolve(); - } catch (err) { - reject(err); - } - }); - res.on("error", reject); - }; - - const req = url.startsWith("https") - ? https.get(url, options, callback) - : http.get(url, options, callback); - - req.on("error", reject); - req.setTimeout(10000, () => { - req.destroy(); - reject(new Error("Download timeout")); - }); - }); - - console.log( - `[LinuxNotification] Avatar downloaded: ${url} -> ${localPath}`, - ); - return localPath; - } catch (error) { - console.error("[LinuxNotification] Failed to download avatar:", error); - return null; - } } export async function showLinuxNotification( data: LinuxNotificationData, ): Promise { + if (process.platform !== "linux") { + return null; + } + + if (!Notification.isSupported()) { + console.warn("[LinuxNotification] Notification API is not supported"); + return null; + } + try { - const bus = await getSessionBus(); - - const appName = "WeFlow"; - const replaceId = 0; - const expireTimeout = data.expireTimeout ?? 5000; - - // 处理头像:下载到本地或使用URL - let appIcon = ""; - let hints: any[] = []; + let iconPath: string | undefined; if (data.avatarUrl) { - // 优先尝试下载到本地 - const localPath = await downloadAvatarToLocal(data.avatarUrl); - if (localPath) { - hints = [["image-path", ["s", localPath]]]; - } + iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined; } - return new Promise((resolve, reject) => { - bus.invoke( - { - destination: BUS_NAME, - path: OBJECT_PATH, - interface: "org.freedesktop.Notifications", - member: "Notify", - signature: "susssasa{sv}i", - body: [ - appName, - replaceId, - appIcon, - data.title, - data.content, - ["default", "打开"], // 提供default action,否则系统不会抛出点击事件 - hints, - // [], // 传空数组以避开a{sv}变体的序列化崩溃,有pendingNotifications映射维护保证不出错 - expireTimeout, - ], - }, - (err: Error | null, result: any) => { - if (err) { - console.error("[LinuxNotification] Notify error:", err); - reject(err); - return; - } - const notificationId = - typeof result === "number" ? result : result[0]; - if (data.sessionId) { - // 依赖Map实现点击追踪,没有使用D-Bus hints - pendingNotifications.set(notificationId, data); - } - console.log( - `[LinuxNotification] Shown notification ${notificationId}: ${data.title}, icon: ${appIcon || "none"}`, - ); - resolve(notificationId); - }, - ); + const notification = new Notification({ + title: data.title, + body: data.content, + icon: iconPath, }); + + const notificationId = nextNotificationId(); + activeNotifications.set(notificationId, notification); + + notification.on("click", () => { + if (data.sessionId) { + triggerNotificationCallback(data.sessionId); + } + }); + + notification.on("close", () => { + clearNotificationState(notificationId); + }); + + notification.on("failed", (_, error) => { + console.error("[LinuxNotification] Notification failed:", error); + clearNotificationState(notificationId); + }); + + const expireTimeout = data.expireTimeout ?? 5000; + if (expireTimeout > 0) { + const timer = setTimeout(() => { + const currentNotification = activeNotifications.get(notificationId); + if (currentNotification) { + currentNotification.close(); + } + }, expireTimeout); + closeTimers.set(notificationId, timer); + } + + notification.show(); + + console.log( + `[LinuxNotification] Shown notification ${notificationId}: ${data.title}`, + ); + + return notificationId; } catch (error) { console.error("[LinuxNotification] Failed to show notification:", error); return null; @@ -194,59 +110,22 @@ export async function showLinuxNotification( export async function closeLinuxNotification( notificationId: number, ): Promise { - try { - const bus = await getSessionBus(); - return new Promise((resolve, reject) => { - bus.invoke( - { - destination: BUS_NAME, - path: OBJECT_PATH, - interface: "org.freedesktop.Notifications", - member: "CloseNotification", - signature: "u", - body: [notificationId], - }, - (err: Error | null) => { - if (err) { - console.error("[LinuxNotification] CloseNotification error:", err); - reject(err); - return; - } - pendingNotifications.delete(notificationId); - resolve(); - }, - ); - }); - } catch (error) { - console.error("[LinuxNotification] Failed to close notification:", error); - } + const notification = activeNotifications.get(notificationId); + if (!notification) return; + notification.close(); + clearNotificationState(notificationId); } export async function getCapabilities(): Promise { - try { - const bus = await getSessionBus(); - return new Promise((resolve, reject) => { - bus.invoke( - { - destination: BUS_NAME, - path: OBJECT_PATH, - interface: "org.freedesktop.Notifications", - member: "GetCapabilities", - }, - (err: Error | null, result: any) => { - if (err) { - console.error("[LinuxNotification] GetCapabilities error:", err); - reject(err); - return; - } - resolve(result as string[]); - }, - ); - }); - } catch (error) { - console.error("[LinuxNotification] Failed to get capabilities:", error); + if (process.platform !== "linux") { return []; } + + if (!Notification.isSupported()) { + return []; + } + + return ["native-notification", "click"]; } export function onNotificationAction(callback: NotificationCallback): void { @@ -262,83 +141,34 @@ export function removeNotificationCallback( } } -function triggerNotificationCallback(sessionId: string): void { - for (const callback of notificationCallbacks) { - try { - callback(sessionId); - } catch (error) { - console.error("[LinuxNotification] Callback error:", error); - } - } -} - export async function initLinuxNotificationService(): Promise { if (process.platform !== "linux") { console.log("[LinuxNotification] Not on Linux, skipping init"); return; } - try { - const bus = await getSessionBus(); - - // 监听底层connection的message事件 - bus.connection.on("message", (msg: any) => { - // type 4表示SIGNAL - if ( - msg.type === 4 && - msg.path === OBJECT_PATH && - msg.interface === "org.freedesktop.Notifications" - ) { - if (msg.member === "ActionInvoked") { - const [notificationId, actionId] = msg.body; - console.log( - `[LinuxNotification] Action invoked: ${notificationId}, ${actionId}`, - ); - - // 如果用户点击了通知本体,actionId会是'default' - if (actionId === "default") { - const data = pendingNotifications.get(notificationId); - if (data?.sessionId) { - triggerNotificationCallback(data.sessionId); - } - } - } - - if (msg.member === "NotificationClosed") { - const [notificationId] = msg.body; - pendingNotifications.delete(notificationId); - } - } - }); - - // AddMatch用来接收信号 - await new Promise((resolve, reject) => { - bus.invoke( - { - destination: "org.freedesktop.DBus", - path: "/org/freedesktop/DBus", - interface: "org.freedesktop.DBus", - member: "AddMatch", - signature: "s", - body: ["type='signal',interface='org.freedesktop.Notifications'"], - }, - (err: Error | null) => { - if (err) { - console.error("[LinuxNotification] AddMatch error:", err); - reject(err); - return; - } - resolve(); - }, - ); - }); - - console.log("[LinuxNotification] Service initialized"); - - // 打印相关日志 - const caps = await getCapabilities(); - console.log("[LinuxNotification] Server capabilities:", caps); - } catch (error) { - console.error("[LinuxNotification] Failed to initialize:", error); + if (!Notification.isSupported()) { + console.warn("[LinuxNotification] Notification API is not supported"); + return; } + + const caps = await getCapabilities(); + console.log("[LinuxNotification] Service initialized with native API:", caps); +} + +export async function shutdownLinuxNotificationService(): Promise { + // 清理所有活动的通知 + for (const [id, notification] of activeNotifications) { + try { + notification.close(); + } catch {} + clearNotificationState(id); + } + + // 清理头像文件缓存 + try { + await avatarFileCache.clearCache(); + } catch {} + + console.log("[LinuxNotification] Service shutdown complete"); } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index dcf6dee..fde2ca7 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -121,6 +121,9 @@ export class WcdbCore { private videoHardlinkCache: Map = new Map() private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private readonly hardlinkCacheMaxEntries = 20000 + private mediaStreamSessionCache: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> | null = null + private mediaStreamSessionCacheAt = 0 + private readonly mediaStreamSessionCacheTtlMs = 12 * 1000 private logTimer: NodeJS.Timeout | null = null private lastLogTail: string | null = null private lastResolvedLogPath: string | null = null @@ -277,7 +280,9 @@ export class WcdbCore { const isLinux = process.platform === 'linux' const isArm64 = process.arch === 'arm64' const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' - const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '') + const legacySubDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '') + const platformDir = isMac ? 'macos' : (isLinux ? 'linux' : 'win32') + const archDir = isMac ? 'universal' : (isArm64 ? 'arm64' : 'x64') const envDllPath = process.env.WCDB_DLL_PATH if (envDllPath && envDllPath.length > 0) { @@ -287,20 +292,33 @@ export class WcdbCore { // 基础路径探测 const isPackaged = typeof process['resourcesPath'] !== 'undefined' const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources') - - const candidates = [ - // 环境变量指定 resource 目录 - process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null, - // 显式 setPaths 设置的路径 - this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null, - // resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll - join(resourcesPath, 'resources', subDir, libName), - // resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构) - join(resourcesPath, subDir, libName), - // CWD fallback - join(process.cwd(), 'resources', subDir, libName) + const roots = [ + process.env.WCDB_RESOURCES_PATH || null, + this.resourcesPath || null, + join(resourcesPath, 'resources'), + resourcesPath, + join(process.cwd(), 'resources') ].filter(Boolean) as string[] + const normalizedArch = process.arch === 'arm64' ? 'arm64' : 'x64' + const relativeCandidates = [ + join('wcdb', platformDir, archDir, libName), + join('wcdb', platformDir, normalizedArch, libName), + join('wcdb', platformDir, 'x64', libName), + join('wcdb', platformDir, 'universal', libName), + join('wcdb', platformDir, libName) + ] + + const candidates: string[] = [] + for (const root of roots) { + for (const relativePath of relativeCandidates) { + candidates.push(join(root, relativePath)) + } + // 兼容旧目录:resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll + candidates.push(join(root, legacySubDir, libName)) + candidates.push(join(root, libName)) + } + for (const path of candidates) { if (existsSync(path)) return path } @@ -1465,6 +1483,11 @@ export class WcdbCore { this.videoHardlinkCache.clear() } + private clearMediaStreamSessionCache(): void { + this.mediaStreamSessionCache = null + this.mediaStreamSessionCacheAt = 0 + } + isReady(): boolean { return this.ensureReady() } @@ -1580,6 +1603,7 @@ export class WcdbCore { this.currentDbStoragePath = null this.initialized = false this.clearHardlinkCaches() + this.clearMediaStreamSessionCache() this.stopLogPolling() } } @@ -1957,7 +1981,7 @@ export class WcdbCore { error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持媒体流扫描,请先更新 wcdb 数据服务' } + if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持资源扫描,请先更新 wcdb 数据服务' } try { const toInt = (value: unknown): number => { const n = Number(value || 0) @@ -2168,37 +2192,64 @@ export class WcdbCore { const offset = Math.max(0, toInt(options?.offset)) const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240)) - const sessionsRes = await this.getSessions() - if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { - return { success: false, error: sessionsRes.error || '读取会话失败' } + const getSessionRows = async (): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> + error?: string + }> => { + const now = Date.now() + const cachedRows = this.mediaStreamSessionCache + if ( + cachedRows && + now - this.mediaStreamSessionCacheAt <= this.mediaStreamSessionCacheTtlMs + ) { + return { success: true, rows: cachedRows } + } + + const sessionsRes = await this.getSessions() + if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { + return { success: false, error: sessionsRes.error || '读取会话失败' } + } + + const rows = (sessionsRes.sessions || []) + .map((row: any) => ({ + sessionId: String( + row.username || + row.user_name || + row.userName || + row.usrName || + row.UsrName || + row.talker || + '' + ).trim(), + displayName: String(row.displayName || row.display_name || row.remark || '').trim(), + sortTimestamp: toInt( + row.sort_timestamp || + row.sortTimestamp || + row.last_timestamp || + row.lastTimestamp || + 0 + ) + })) + .filter((row) => Boolean(row.sessionId)) + .sort((a, b) => b.sortTimestamp - a.sortTimestamp) + + this.mediaStreamSessionCache = rows + this.mediaStreamSessionCacheAt = now + return { success: true, rows } } - const sessions = (sessionsRes.sessions || []) - .map((row: any) => ({ - sessionId: String( - row.username || - row.user_name || - row.userName || - row.usrName || - row.UsrName || - row.talker || - '' - ).trim(), - displayName: String(row.displayName || row.display_name || row.remark || '').trim(), - sortTimestamp: toInt( - row.sort_timestamp || - row.sortTimestamp || - row.last_timestamp || - row.lastTimestamp || - 0 - ) - })) - .filter((row) => Boolean(row.sessionId)) - .sort((a, b) => b.sortTimestamp - a.sortTimestamp) + let sessionRows: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> = [] + if (requestedSessionId) { + sessionRows = [{ sessionId: requestedSessionId, displayName: requestedSessionId, sortTimestamp: 0 }] + } else { + const sessionsRowsRes = await getSessionRows() + if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) { + return { success: false, error: sessionsRowsRes.error || '读取会话失败' } + } + sessionRows = sessionsRowsRes.rows + } - const sessionRows = requestedSessionId - ? sessions.filter((row) => row.sessionId === requestedSessionId) - : sessions if (sessionRows.length === 0) { return { success: true, items: [], hasMore: false, nextOffset: offset } } @@ -2219,10 +2270,10 @@ export class WcdbCore { outHasMore ) if (result !== 0 || !outPtr[0]) { - return { success: false, error: `扫描媒体流失败: ${result}` } + return { success: false, error: `扫描资源失败: ${result}` } } const jsonStr = this.decodeJsonPtr(outPtr[0]) - if (!jsonStr) return { success: false, error: '解析媒体流失败' } + if (!jsonStr) return { success: false, error: '解析资源失败' } const rows = JSON.parse(jsonStr) const list = Array.isArray(rows) ? rows as Array> : [] @@ -2254,19 +2305,39 @@ export class WcdbCore { rawMessageContent && (rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg')) ) - const content = useRawMessageContent - ? rawMessageContent - : decodeMessageContent(rawMessageContent, rawCompressContent) + const decodeContentIfNeeded = (): string => { + if (useRawMessageContent) return rawMessageContent + if (!rawMessageContent && !rawCompressContent) return '' + return decodeMessageContent(rawMessageContent, rawCompressContent) + } const packedPayload = extractPackedPayload(row) const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5']) - const imageMd5 = localType === 3 - ? (imageMd5ByColumn || extractImageMd5(content) || extractHexMd5(packedPayload) || undefined) - : undefined - const imageDatName = localType === 3 ? (extractImageDatName(row, content) || undefined) : undefined const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5']) - const videoMd5 = localType === 43 - ? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined) - : undefined + + let content = '' + let imageMd5: string | undefined + let imageDatName: string | undefined + let videoMd5: string | undefined + + if (localType === 3) { + imageMd5 = imageMd5ByColumn || extractHexMd5(packedPayload) || undefined + imageDatName = extractImageDatName(row, '') || undefined + if (!imageMd5 || !imageDatName) { + content = decodeContentIfNeeded() + if (!imageMd5) imageMd5 = extractImageMd5(content) || extractHexMd5(packedPayload) || undefined + if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined + } + } else if (localType === 43) { + videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined + if (!videoMd5) { + content = decodeContentIfNeeded() + videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined + } else if (useRawMessageContent) { + // 占位态标题只依赖简单 XML,已带 md5 时不做额外解压 + content = rawMessageContent + } + } + return { sessionId, sessionDisplayName: sessionNameMap.get(sessionId) || sessionId, @@ -2280,7 +2351,7 @@ export class WcdbCore { imageMd5, imageDatName, videoMd5, - content: content || undefined + content: localType === 43 ? (content || undefined) : undefined } }) diff --git a/electron/types/dbus.d.ts b/electron/types/dbus.d.ts deleted file mode 100644 index 9585a42..0000000 --- a/electron/types/dbus.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -declare module 'dbus-native' { - namespace dbus { - interface DBusConnection { - invoke(options: any, callback: (err: Error | null, result?: any) => void): void; - on(event: string, listener: Function): void; - // 底层connection,用于监听signal - connection: { - on(event: string, listener: Function): void; - }; - } - - // 声明sessionBus方法 - function sessionBus(): DBusConnection; - function systemBus(): DBusConnection; - } - - export = dbus; -} diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index 587f43e..f3c8eca 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -27,6 +27,14 @@ export function destroyNotificationWindow() { } lastNotificationData = null; + // Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出) + if (isLinux && linuxNotificationService) { + linuxNotificationService.shutdownLinuxNotificationService().catch((error) => { + console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error); + }); + linuxNotificationService = null; + } + if (!notificationWindow || notificationWindow.isDestroyed()) { notificationWindow = null; return; diff --git a/package-lock.json b/package-lock.json index 7f6e644..0c06ec1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "hasInstallScript": true, "dependencies": { "@vscode/sudo-prompt": "^9.3.2", - "dbus-native": "^0.4.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "electron-store": "^11.0.2", @@ -45,7 +44,7 @@ "sharp": "^0.34.5", "typescript": "^6.0.2", "vite": "^7.3.2", - "vite-plugin-electron": "^0.28.8", + "vite-plugin-electron": "^0.29.1", "vite-plugin-electron-renderer": "^0.14.6" } }, @@ -3084,25 +3083,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/abstract-socket": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz", - "integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "dependencies": { - "bindings": "^1.2.1", - "nan": "^2.12.1" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3615,16 +3595,6 @@ "node": "*" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4459,27 +4429,6 @@ "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, - "node_modules/dbus-native": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/dbus-native/-/dbus-native-0.4.0.tgz", - "integrity": "sha512-i3zvY3tdPEOaMgmK4riwupjDYRJ53rcE1Kj8rAgnLOFmBd0DekUih59qv8v+Oyils/U9p+s4sSsaBzHWLztI+Q==", - "license": "MIT", - "dependencies": { - "event-stream": "^4.0.0", - "hexy": "^0.2.10", - "long": "^4.0.0", - "optimist": "^0.6.1", - "put": "0.0.6", - "safe-buffer": "^5.1.1", - "xml2js": "^0.4.17" - }, - "bin": { - "dbus2js": "bin/dbus2js.js" - }, - "optionalDependencies": { - "abstract-socket": "^2.0.0" - } - }, "node_modules/debounce-fn": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", @@ -4848,12 +4797,6 @@ "node": ">= 0.4" } }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "license": "MIT" - }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -5379,21 +5322,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/event-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz", - "integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==", - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.1", - "from": "^0.1.7", - "map-stream": "0.0.7", - "pause-stream": "^0.0.11", - "split": "^1.0.1", - "stream-combiner": "^0.2.2", - "through": "^2.3.8" - } - }, "node_modules/exceljs": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", @@ -5570,13 +5498,6 @@ "node": ">= 6" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "optional": true - }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -5664,12 +5585,6 @@ "node": ">= 6" } }, - "node_modules/from": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", - "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", - "license": "MIT" - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -6069,15 +5984,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hexy": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz", - "integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==", - "license": "MIT", - "bin": { - "hexy": "bin/hexy_cmd.js" - } - }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -6806,12 +6712,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -6874,12 +6774,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/map-stream": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", - "integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==", - "license": "MIT" - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -8023,13 +7917,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/nan": { - "version": "2.26.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz", - "integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==", - "license": "MIT", - "optional": true - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8222,22 +8109,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==", - "license": "MIT/X11", - "dependencies": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - } - }, - "node_modules/optimist/node_modules/minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==", - "license": "MIT" - }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -8387,18 +8258,6 @@ "dev": true, "license": "ISC" }, - "node_modules/pause-stream": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", - "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", - "license": [ - "MIT", - "Apache2" - ], - "dependencies": { - "through": "~2.3" - } - }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -8597,15 +8456,6 @@ "node": ">=6" } }, - "node_modules/put": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/put/-/put-0.0.6.tgz", - "integrity": "sha512-w0szIZ2NkqznMFqxYPRETCIi+q/S8UKis9F4yOl6/N9NDCZmbjZZT85aI4FgJf3vIPrzMPX60+odCLOaYxNWWw==", - "license": "MIT/X11", - "engines": { - "node": ">=0.3.0" - } - }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -9467,18 +9317,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/split": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", - "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", - "license": "MIT", - "dependencies": { - "through": "2" - }, - "engines": { - "node": "*" - } - }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -9510,16 +9348,6 @@ "node": ">= 6" } }, - "node_modules/stream-combiner": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz", - "integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==", - "license": "MIT", - "dependencies": { - "duplexer": "~0.1.1", - "through": "~2.3.4" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9788,12 +9616,6 @@ "utrie": "^1.0.2" } }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "license": "MIT" - }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -10380,15 +10202,6 @@ "node": ">= 8" } }, - "node_modules/wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -10432,28 +10245,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/xml2js": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xml2js/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 01bdbc8..0f05abe 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/Jasonzhu1207/WeFlow" + "url": "https://github.com/hicccc77/WeFlow" }, "//": "二改不应改变此处的作者与应用信息", "scripts": { @@ -24,7 +24,6 @@ }, "dependencies": { "@vscode/sudo-prompt": "^9.3.2", - "dbus-native": "^0.4.0", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "electron-store": "^11.0.2", @@ -59,7 +58,7 @@ "sharp": "^0.34.5", "typescript": "^6.0.2", "vite": "^7.3.2", - "vite-plugin-electron": "^0.28.8", + "vite-plugin-electron": "^0.29.1", "vite-plugin-electron-renderer": "^0.14.6" }, "pnpm": { @@ -71,14 +70,16 @@ "lodash": ">=4.17.21", "brace-expansion": ">=1.1.11", "picomatch": ">=2.3.1", - "ajv": ">=8.18.0" + "ajv": ">=8.18.0", + "ajv-keywords@3>ajv": "^6.12.6", + "@develar/schema-utils>ajv": "^6.12.6" } }, "build": { "appId": "com.WeFlow.app", "publish": { "provider": "github", - "owner": "Jasonzhu1207", + "owner": "hicccc77", "repo": "WeFlow", "releaseType": "release" }, @@ -97,7 +98,7 @@ "gatekeeperAssess": false, "entitlements": "electron/entitlements.mac.plist", "entitlementsInherit": "electron/entitlements.mac.plist", - "icon": "resources/icon.icns" + "icon": "resources/icons/macos/icon.icns" }, "win": { "target": [ @@ -106,19 +107,19 @@ "icon": "public/icon.ico", "extraFiles": [ { - "from": "resources/msvcp140.dll", + "from": "resources/runtime/win32/msvcp140.dll", "to": "." }, { - "from": "resources/msvcp140_1.dll", + "from": "resources/runtime/win32/msvcp140_1.dll", "to": "." }, { - "from": "resources/vcruntime140.dll", + "from": "resources/runtime/win32/vcruntime140.dll", "to": "." }, { - "from": "resources/vcruntime140_1.dll", + "from": "resources/runtime/win32/vcruntime140_1.dll", "to": "." } ] @@ -134,7 +135,7 @@ "synopsis": "WeFlow for Linux", "extraFiles": [ { - "from": "resources/linux/install.sh", + "from": "resources/installer/linux/install.sh", "to": "install.sh" } ] @@ -189,7 +190,7 @@ "node_modules/sherpa-onnx-*/**/*", "node_modules/ffmpeg-static/**/*" ], - "icon": "resources/icon.icns" + "icon": "resources/icons/macos/icon.icns" }, "overrides": { "picomatch": "^4.0.4", diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll deleted file mode 100644 index 78747ac..0000000 Binary files a/resources/arm64/wcdb_api.dll and /dev/null differ diff --git a/resources/icon.icns b/resources/icons/macos/icon.icns similarity index 100% rename from resources/icon.icns rename to resources/icons/macos/icon.icns diff --git a/resources/linux/install.sh b/resources/installer/linux/install.sh similarity index 100% rename from resources/linux/install.sh rename to resources/installer/linux/install.sh diff --git a/resources/xkey_helper_linux b/resources/key/linux/x64/xkey_helper_linux old mode 100755 new mode 100644 similarity index 100% rename from resources/xkey_helper_linux rename to resources/key/linux/x64/xkey_helper_linux diff --git a/resources/image_scan_entitlements.plist b/resources/key/macos/source/image_scan_entitlements.plist similarity index 100% rename from resources/image_scan_entitlements.plist rename to resources/key/macos/source/image_scan_entitlements.plist diff --git a/resources/image_scan_helper.c b/resources/key/macos/source/image_scan_helper.c similarity index 100% rename from resources/image_scan_helper.c rename to resources/key/macos/source/image_scan_helper.c diff --git a/resources/image_scan_helper b/resources/key/macos/universal/image_scan_helper old mode 100755 new mode 100644 similarity index 100% rename from resources/image_scan_helper rename to resources/key/macos/universal/image_scan_helper diff --git a/resources/libwx_key.dylib b/resources/key/macos/universal/libwx_key.dylib old mode 100755 new mode 100644 similarity index 100% rename from resources/libwx_key.dylib rename to resources/key/macos/universal/libwx_key.dylib diff --git a/resources/xkey_helper b/resources/key/macos/universal/xkey_helper old mode 100755 new mode 100644 similarity index 100% rename from resources/xkey_helper rename to resources/key/macos/universal/xkey_helper diff --git a/resources/xkey_helper_macos b/resources/key/macos/universal/xkey_helper_macos similarity index 100% rename from resources/xkey_helper_macos rename to resources/key/macos/universal/xkey_helper_macos diff --git a/resources/wx_key.dll b/resources/key/win32/x64/wx_key.dll similarity index 100% rename from resources/wx_key.dll rename to resources/key/win32/x64/wx_key.dll diff --git a/resources/libwcdb_api.dylib b/resources/libwcdb_api.dylib deleted file mode 100755 index d185cfc..0000000 Binary files a/resources/libwcdb_api.dylib and /dev/null differ diff --git a/resources/libwcdb_api.so b/resources/libwcdb_api.so deleted file mode 100755 index d3c686a..0000000 Binary files a/resources/libwcdb_api.so and /dev/null differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib deleted file mode 100755 index 26b44d2..0000000 Binary files a/resources/macos/libwcdb_api.dylib and /dev/null differ diff --git a/resources/msvcp140.dll b/resources/runtime/win32/msvcp140.dll similarity index 100% rename from resources/msvcp140.dll rename to resources/runtime/win32/msvcp140.dll diff --git a/resources/msvcp140_1.dll b/resources/runtime/win32/msvcp140_1.dll similarity index 100% rename from resources/msvcp140_1.dll rename to resources/runtime/win32/msvcp140_1.dll diff --git a/resources/vcruntime140.dll b/resources/runtime/win32/vcruntime140.dll similarity index 100% rename from resources/vcruntime140.dll rename to resources/runtime/win32/vcruntime140.dll diff --git a/resources/vcruntime140_1.dll b/resources/runtime/win32/vcruntime140_1.dll similarity index 100% rename from resources/vcruntime140_1.dll rename to resources/runtime/win32/vcruntime140_1.dll diff --git a/resources/linux/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so old mode 100755 new mode 100644 similarity index 66% rename from resources/linux/libwcdb_api.so rename to resources/wcdb/linux/x64/libwcdb_api.so index 0fa218c..8f698f3 Binary files a/resources/linux/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/macos/libWCDB.dylib b/resources/wcdb/macos/universal/libWCDB.dylib old mode 100755 new mode 100644 similarity index 100% rename from resources/macos/libWCDB.dylib rename to resources/wcdb/macos/universal/libWCDB.dylib diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib new file mode 100644 index 0000000..5a81c68 Binary files /dev/null and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/arm64/WCDB.dll b/resources/wcdb/win32/arm64/WCDB.dll similarity index 100% rename from resources/arm64/WCDB.dll rename to resources/wcdb/win32/arm64/WCDB.dll diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll new file mode 100644 index 0000000..5f144d8 Binary files /dev/null and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/SDL2.dll b/resources/wcdb/win32/x64/SDL2.dll similarity index 100% rename from resources/SDL2.dll rename to resources/wcdb/win32/x64/SDL2.dll diff --git a/resources/WCDB.dll b/resources/wcdb/win32/x64/WCDB.dll similarity index 100% rename from resources/WCDB.dll rename to resources/wcdb/win32/x64/WCDB.dll diff --git a/resources/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll similarity index 100% rename from resources/wcdb_api.dll rename to resources/wcdb/win32/x64/wcdb_api.dll diff --git a/src/App.tsx b/src/App.tsx index f54442d..c9c574b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,44 +107,6 @@ function App() { const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false) const [analyticsConsent, setAnalyticsConsent] = useState(null) - const [showWaylandWarning, setShowWaylandWarning] = useState(false) - - useEffect(() => { - const checkWaylandStatus = async () => { - try { - // 防止在非客户端环境报错,先检查 API 是否存在 - if (!window.electronAPI?.app?.checkWayland) return - - // 通过 configService 检查是否已经弹过窗 - const hasWarned = await window.electronAPI.config.get('waylandWarningShown') - - if (!hasWarned) { - const isWayland = await window.electronAPI.app.checkWayland() - if (isWayland) { - setShowWaylandWarning(true) - } - } - } catch (e) { - console.error('检查 Wayland 状态失败:', e) - } - } - - // 只有在协议同意之后并且已经进入主应用流程才检查 - if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) { - checkWaylandStatus() - } - }, [isAgreementWindow, isOnboardingWindow, agreementLoading]) - - const handleDismissWaylandWarning = async () => { - try { - // 记录到本地配置中,下次不再提示 - await window.electronAPI.config.set('waylandWarningShown', true) - } catch (e) { - console.error('保存 Wayland 提示状态失败:', e) - } - setShowWaylandWarning(false) - } - useEffect(() => { if (location.pathname !== '/settings') { settingsBackgroundRef.current = location @@ -339,6 +301,21 @@ function App() { } }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow]) + // 监听通知点击导航事件 + useEffect(() => { + if (isNotificationWindow) return + + const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => { + if (!sessionId) return + // 导航到聊天页面,通过URL参数让ChatPage接收sessionId + navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true }) + }) + + return () => { + removeListener?.() + } + }, [navigate, isNotificationWindow]) + // 解锁后显示暂存的更新弹窗 useEffect(() => { if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) { @@ -670,33 +647,6 @@ function App() {
)} - {/*{showWaylandWarning && (*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/* */} - {/*

环境兼容性提示 (Wayland)

*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*

检测到您当前正在使用 Wayland 显示服务器。

*/} - {/*

在 Wayland 环境下,出于系统级的安全与设计机制,应用程序无法直接控制新弹出窗口的位置

*/} - {/*

这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。

*/} - {/*
*/} - {/*

如果您觉得窗口位置异常严重影响了使用体验,建议尝试:

*/} - {/*

1. 在系统登录界面,将会话切换回 X11 (Xorg) 模式。

*/} - {/*

2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。

*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/* */} - {/*
*/} - {/*
*/} - {/*
*/} - {/*
*/} - {/*)}*/} - {/* 更新提示对话框 */} = [ + { value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' }, + { value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' } +] + const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => { @@ -56,12 +62,15 @@ export function ExportDefaultsSettingsForm({ layout = 'stacked' }: ExportDefaultsSettingsFormProps) { const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) + const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false) const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) const exportExcelColumnsDropdownRef = useRef(null) + const exportFileNamingModeDropdownRef = useRef(null) const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState('classic') const [exportDefaultMedia, setExportDefaultMedia] = useState({ images: true, videos: true, @@ -76,10 +85,11 @@ export function ExportDefaultsSettingsForm({ useEffect(() => { let cancelled = false void (async () => { - const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ + const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ configService.getExportDefaultFormat(), configService.getExportDefaultAvatars(), configService.getExportDefaultDateRange(), + configService.getExportDefaultFileNamingMode(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), @@ -91,6 +101,7 @@ export function ExportDefaultsSettingsForm({ setExportDefaultFormat(savedFormat || 'excel') setExportDefaultAvatars(savedAvatars ?? true) setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) + setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') setExportDefaultMedia(savedMedia ?? { images: true, videos: true, @@ -114,15 +125,19 @@ export function ExportDefaultsSettingsForm({ if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { setShowExportExcelColumnsSelect(false) } + if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) { + setShowExportFileNamingModeSelect(false) + } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportExcelColumnsSelect]) + }, [showExportExcelColumnsSelect, showExportFileNamingModeSelect]) const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) + const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode]) const notify = (text: string, success = true) => { onNotify?.(text, success) @@ -224,6 +239,7 @@ export function ExportDefaultsSettingsForm({ className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`} onClick={() => { setShowExportExcelColumnsSelect(false) + setShowExportFileNamingModeSelect(false) setIsExportDateRangeDialogOpen(true) }} > @@ -247,6 +263,50 @@ export function ExportDefaultsSettingsForm({ }} /> +
+
+ + 控制导出文件名是否包含时间范围 +
+
+
+ + {showExportFileNamingModeSelect && ( +
+ {exportFileNamingModeOptions.map((option) => ( + + ))} +
+ )} +
+
+
+
@@ -259,6 +319,7 @@ export function ExportDefaultsSettingsForm({ className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`} onClick={() => { setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) + setShowExportFileNamingModeSelect(false) setIsExportDateRangeDialogOpen(false) }} > diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 8190a19..22d2e56 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1965,6 +1965,10 @@ color: var(--on-primary); border-radius: 18px 18px 4px 18px; } + + .bubble-body { + align-items: flex-end; + } } // 对方发送的消息 - 左侧白色 @@ -1974,6 +1978,10 @@ color: var(--text-primary); border-radius: 18px 18px 18px 4px; } + + .bubble-body { + align-items: flex-start; + } } &.system { @@ -2038,6 +2046,12 @@ white-space: pre-wrap; } +// 让文字气泡按内容收缩,不被群昵称行宽度牵连 +.message-bubble:not(.system) .bubble-content { + width: fit-content; + max-width: 100%; +} + // 表情包消息 .message-bubble.emoji { .bubble-content { diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 5e86cc5..4da71be 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react' -import { useNavigate } from 'react-router-dom' +import { useNavigate, useLocation } from 'react-router-dom' import { createPortal } from 'react-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { useShallow } from 'zustand/react/shallow' @@ -1142,6 +1142,7 @@ function ChatPage(props: ChatPageProps) { const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType]) const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const navigate = useNavigate() + const location = useLocation() const { isConnected, @@ -5350,6 +5351,19 @@ function ChatPage(props: ChatPageProps) { selectSessionById ]) + // 监听URL参数中的sessionId,用于通知点击导航 + useEffect(() => { + if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理 + const params = new URLSearchParams(location.search) + const urlSessionId = params.get('sessionId') + if (!urlSessionId) return + if (!isConnected || isConnecting) return + if (currentSessionId === urlSessionId) return + selectSessionById(urlSessionId) + // 选中后清除URL参数,避免影响后续用户手动切换会话 + navigate('/chat', { replace: true }) + }, [standaloneSessionWindow, location.search, isConnected, isConnecting, currentSessionId, selectSessionById, navigate]) + useEffect(() => { if (!standaloneSessionWindow || !normalizedInitialSessionId) return if (!isConnected || isConnecting) { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 750d496..1f95d36 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1621,6 +1621,7 @@ function ExportPage() { const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState('classic') const [exportDefaultMedia, setExportDefaultMedia] = useState({ images: true, videos: true, @@ -2270,7 +2271,7 @@ function ExportPage() { setIsBaseConfigLoading(true) let isReady = true try { - const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ + const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultAvatars(), @@ -2287,6 +2288,7 @@ function ExportPage() { configService.getExportWriteLayout(), configService.getExportSessionNamePrefixEnabled(), configService.getExportDefaultDateRange(), + configService.getExportDefaultFileNamingMode(), ensureExportCacheScope() ]) @@ -2318,6 +2320,7 @@ function ExportPage() { setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true) + setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange) @@ -4397,6 +4400,7 @@ function ExportPage() { displayNamePreference: options.displayNamePreference, exportConcurrency: options.exportConcurrency, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + fileNamingMode: exportDefaultFileNamingMode, sessionLayout, sessionNameWithTypePrefix, dateRange: options.useAllTime @@ -7089,6 +7093,9 @@ function ExportPage() { if (patch.dateRange) { setExportDefaultDateRangeSelection(patch.dateRange) } + if (patch.fileNamingMode) { + setExportDefaultFileNamingMode(patch.fileNamingMode) + } if (patch.media) { const mediaPatch = patch.media setExportDefaultMedia(mediaPatch) diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx index f58c729..7518647 100644 --- a/src/pages/ResourcesPage.tsx +++ b/src/pages/ResourcesPage.tsx @@ -1,6 +1,7 @@ import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react' import { VirtuosoGrid } from 'react-virtuoso' +import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' import './ResourcesPage.scss' type MediaTab = 'image' | 'video' @@ -35,10 +36,14 @@ type DialogState = { onConfirm?: (() => void) | null } -const PAGE_SIZE = 120 -const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18 -const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36 -const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4 +const PAGE_SIZE = 96 +const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12 +const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24 +const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 3 +const INITIAL_IMAGE_PRELOAD_END = 48 +const INITIAL_IMAGE_RESOLVE_END = 12 +const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250 +const TASK_PROGRESS_UPDATE_MAX_STEPS = 100 const GridList = forwardRef>(function GridList(props, ref) { const { className = '', ...rest } = props @@ -409,7 +414,13 @@ function ResourcesPage() { } try { - await window.electronAPI.chat.connect() + if (reset) { + const connectResult = await window.electronAPI.chat.connect() + if (!connectResult.success) { + setError(connectResult.error || '连接数据库失败') + return + } + } const requestOffset = reset ? 0 : nextOffset const streamResult = await window.electronAPI.chat.getMediaStream({ sessionId: selectedContact === 'all' ? undefined : selectedContact, @@ -524,7 +535,6 @@ function ResourcesPage() { let cancelled = false const run = async () => { try { - await window.electronAPI.chat.connect() const sessionResult = await window.electronAPI.chat.getSessions() if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) { const initialNameMap: Record = {} @@ -674,7 +684,10 @@ function ResourcesPage() { resolvingImageCacheBatchRef.current = true void (async () => { try { - const result = await window.electronAPI.image.resolveCacheBatch(payloads, { disableUpdateCheck: true }) + const result = await window.electronAPI.image.resolveCacheBatch(payloads, { + disableUpdateCheck: true, + allowCacheIndex: false + }) const rows = Array.isArray(result?.rows) ? result.rows : [] const pathPatch: Record = {} const updatePatch: Record = {} @@ -741,7 +754,10 @@ function ResourcesPage() { if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break } if (payloads.length === 0) return - void window.electronAPI.image.preload(payloads, { allowDecrypt: false }) + void window.electronAPI.image.preload(payloads, { + allowDecrypt: false, + allowCacheIndex: false + }) }, [displayItems]) const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise => { @@ -813,14 +829,18 @@ function ResourcesPage() { if (!pending) return pendingRangeRef.current = null if (tab === 'image') { - preloadImageCacheRange(pending.start - 8, pending.end + 32) - resolveImageCacheRange(pending.start - 2, pending.end + 8) + preloadImageCacheRange(pending.start - 4, pending.end + 20) + resolveImageCacheRange(pending.start - 1, pending.end + 6) return } resolvePosterRange(pending.start, pending.end) }, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab]) const scheduleRangeResolve = useCallback((start: number, end: number) => { + const previous = pendingRangeRef.current + if (previous && start >= previous.start && end <= previous.end) { + return + } pendingRangeRef.current = { start, end } if (rangeTimerRef.current !== null) { window.clearTimeout(rangeTimerRef.current) @@ -832,8 +852,8 @@ function ResourcesPage() { useEffect(() => { if (displayItems.length === 0) return if (tab === 'image') { - preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80)) - resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20)) + preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END)) + resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END)) return } resolvePosterRange(0, Math.min(displayItems.length - 1, 12)) @@ -1057,25 +1077,61 @@ function ResourcesPage() { setBatchBusy(true) let success = 0 + let failed = 0 const previewPatch: Record = {} const updatePatch: Record = {} + const taskId = registerBackgroundTask({ + sourcePage: 'other', + title: '资源页图片批量解密', + detail: `正在解密图片(0/${imageItems.length})`, + progressText: `0 / ${imageItems.length}`, + cancelable: false + }) try { + let completed = 0 + const progressStep = Math.max(1, Math.floor(imageItems.length / TASK_PROGRESS_UPDATE_MAX_STEPS)) + let lastProgressBucket = 0 + let lastProgressUpdateAt = Date.now() + const updateTaskProgress = (force: boolean = false) => { + const now = Date.now() + const bucket = Math.floor(completed / progressStep) + const crossedBucket = bucket !== lastProgressBucket + const intervalReached = now - lastProgressUpdateAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS + if (!force && !crossedBucket && !intervalReached) return + updateBackgroundTask(taskId, { + detail: `正在解密图片(${completed}/${imageItems.length})`, + progressText: `${completed} / ${imageItems.length}` + }) + lastProgressBucket = bucket + lastProgressUpdateAt = now + } for (const item of imageItems) { - if (!item.imageMd5 && !item.imageDatName) continue + if (!item.imageMd5 && !item.imageDatName) { + failed += 1 + completed += 1 + updateTaskProgress() + continue + } const result = await window.electronAPI.image.decrypt({ sessionId: item.sessionId, imageMd5: item.imageMd5 || undefined, imageDatName: item.imageDatName || undefined, force: true }) - if (!result?.success) continue - success += 1 - if (result.localPath) { - const key = getItemKey(item) - previewPatch[key] = result.localPath - updatePatch[key] = isLikelyThumbnailPreview(result.localPath) + if (!result?.success) { + failed += 1 + } else { + success += 1 + if (result.localPath) { + const key = getItemKey(item) + previewPatch[key] = result.localPath + updatePatch[key] = isLikelyThumbnailPreview(result.localPath) + } } + completed += 1 + updateTaskProgress() } + updateTaskProgress(true) if (Object.keys(previewPatch).length > 0) { setPreviewPathMap((prev) => ({ ...prev, ...previewPatch })) @@ -1083,8 +1139,17 @@ function ResourcesPage() { if (Object.keys(updatePatch).length > 0) { setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch })) } - setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`) - showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成') + setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`) + showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成') + finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', { + detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`, + progressText: `成功 ${success} / 失败 ${failed}` + }) + } catch (e) { + finishBackgroundTask(taskId, 'failed', { + detail: `资源页图片批量解密失败:${String(e)}` + }) + showAlert(`批量解密失败:${String(e)}`, '批量解密失败') } finally { setBatchBusy(false) } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 92a9b5f..a1149d0 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -238,23 +238,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('') const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('') - const [isWayland, setIsWayland] = useState(false) - useEffect(() => { - const checkWaylandStatus = async () => { - if (window.electronAPI?.app?.checkWayland) { - try { - const wayland = await window.electronAPI.app.checkWayland() - setIsWayland(wayland) - } catch (e) { - console.error('检查 Wayland 状态失败:', e) - } - } - } - checkWaylandStatus() - }, []) - - - // 检查 Hello 可用性 useEffect(() => { setHelloAvailable(isWindows) @@ -1474,13 +1457,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { { value: 'quote-top' as const, label: '引用在上', - description: '更接近当前 WeFlow 风格', successMessage: '已切换为引用在上样式' }, { value: 'quote-bottom' as const, label: '正文在上', - description: '更接近微信 / 密语风格', successMessage: '已切换为正文在上样式' } ].map(option => { @@ -1530,7 +1511,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{option.label} - {option.description}
@@ -1672,7 +1652,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- 开启后,收���新消息时将显示桌面弹窗通知 + 开启后,收到新消息时将显示桌面弹窗通知
{notificationEnabled ? '已开启' : '已关闭'}