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 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
-
+
----
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
> [!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
-
```
+## 构建状态
+
+用于开发者排查发布链路,普通用户可忽略:
+
+
+
+
+
+
+
+
+
+
+
+
+
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
@@ -120,18 +121,16 @@ npm run dev
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
-
-> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
-
+> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
## Star History
-
-
-
-
-
+
+
+
+
+
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 ? '已开启' : '已关闭'}