chore: merge upstream main into fork main

This commit is contained in:
Jason
2026-04-10 20:45:04 +08:00
62 changed files with 1693 additions and 901 deletions

View File

@@ -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}'

View File

@@ -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}'

View File

@@ -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

View File

@@ -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

2
.gitignore vendored
View File

@@ -56,6 +56,8 @@ Thumbs.db
*.aps
wcdb/
!resources/wcdb/
!resources/wcdb/**
xkey/
server/
*info

View File

@@ -1,34 +1,23 @@
# WeFlow
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
---
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
<p align="center">
<img src="app.png" alt="WeFlow" width="90%">
<img src="app.png" alt="WeFlow 应用预览" width="90%">
</p>
---
<p align="center">
<a href="https://github.com/hicccc77/WeFlow/stargazers">
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square" alt="Stargazers">
</a>
<a href="https://github.com/hicccc77/WeFlow/network/members">
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
</a>
<a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
</a>
<a href="https://github.com/hicccc77/WeFlow/releases">
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square" alt="Downloads" />
</a>
<a href="https://t.me/weflow_cc">
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
</a>
<!-- 第一行修复样式 -->
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
<br><br>
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
</p>
> [!TIP]
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
@@ -47,14 +36,12 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
## 支持平台与设备
| 平台 | 设备/架构 | 安装包 |
|------|----------|--------|
| Windows | Windows10+、x64amd64 | `.exe` |
| macOS | Apple SiliconM 系列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
```
## 构建状态
用于开发者排查发布链路,普通用户可忽略:
<p align="left">
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/release.yml">
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/release.yml?branch=main&label=Release&style=flat&labelColor=111827&color=22C55E" alt="Release Workflow">
</a>
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/preview-nightly-main.yml">
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/preview-nightly-main.yml?branch=main&label=Preview%20Nightly&style=flat&labelColor=111827&color=F59E0B" alt="Preview Nightly Workflow">
</a>
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/dev-daily-fixed.yml">
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/dev-daily-fixed.yml?branch=dev&label=Dev%20Daily&style=flat&labelColor=111827&color=A78BFA" alt="Dev Daily Workflow">
</a>
</p>
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
@@ -120,10 +121,8 @@ npm run dev
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
## Star History
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">

View File

@@ -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

View File

@@ -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 = <T>(task: () => Promise<T>, 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<string>()
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<void>((resolve) => {
if (mainWindowReady) {
resolve()

View File

@@ -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)

View File

@@ -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<string, Promise<string | null>> =
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<void> {
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<void> {
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<void> {
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<string | null> {
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<string | null>((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<string | null> {
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<void> {
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<number> {
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();

View File

@@ -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) {

View File

@@ -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')

View File

@@ -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<string>): Promise<string> {
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<string>()
const reservedOutputPaths = new Set<string>()
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)

View File

@@ -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<DecryptResult & { hasUpdate?: boolean }> {
if (payload.allowCacheIndex !== false) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
@@ -673,14 +676,14 @@ 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)
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
@@ -689,25 +692,37 @@ export class ImageDecryptService {
if (hdPath) return hdPath
}
}
}
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail)
for (const searchName of searchNames) {
const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
if (datPath) {
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath })
this.resolvedCache.set(imageDatName, datPath)
this.cacheDatPath(accountDir, imageDatName, 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
}
const normalized = this.normalizeDatBase(imageDatName)
if (normalized !== imageDatName.toLowerCase()) {
}
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, normalized, path: normalizedPath })
this.resolvedCache.set(imageDatName, normalizedPath)
this.cacheDatPath(accountDir, imageDatName, 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, normalized })
}
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`
]

View File

@@ -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

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
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}`

View File

@@ -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'))
}

View File

@@ -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) {

View File

@@ -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<void> {
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<string> {
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 以管理员权限运行 helperSIP 下 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<typeof spawn>
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 {

View File

@@ -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<number, LinuxNotificationData> = new Map();
let notificationCounter = 1;
const activeNotifications: Map<number, Notification> = new Map();
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
// 头像缓存url->localFilePath
const avatarCache: Map<string, string> = new Map();
// 缓存目录
let avatarCacheDir: string | null = null;
async function getSessionBus(): Promise<dbus.DBusConnection> {
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<string> {
if (!avatarCacheDir) {
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars");
try {
await fs.mkdir(avatarCacheDir, { recursive: true });
} catch (error) {
console.error(
"[LinuxNotification] Failed to create avatar cache dir:",
error,
);
function clearNotificationState(notificationId: number): void {
activeNotifications.delete(notificationId);
const timer = closeTimers.get(notificationId);
if (timer) {
clearTimeout(timer);
closeTimers.delete(notificationId);
}
}
return avatarCacheDir;
}
// 下载头像到本地临时文件
async function downloadAvatarToLocal(url: string): Promise<string | null> {
// 检查缓存
if (avatarCache.has(url)) {
return avatarCache.get(url) || null;
}
function triggerNotificationCallback(sessionId: string): void {
for (const callback of notificationCallbacks) {
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<void>((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;
callback(sessionId);
} catch (error) {
console.error("[LinuxNotification] Failed to download avatar:", error);
return null;
console.error("[LinuxNotification] Callback error:", error);
}
}
}
export async function showLinuxNotification(
data: LinuxNotificationData,
): Promise<number | null> {
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<void> {
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<string[]> {
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<void> {
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<void>((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);
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
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);
}
console.log("[LinuxNotification] Service initialized with native API:", caps);
}
export async function shutdownLinuxNotificationService(): Promise<void> {
// 清理所有活动的通知
for (const [id, notification] of activeNotifications) {
try {
notification.close();
} catch {}
clearNotificationState(id);
}
// 清理头像文件缓存
try {
await avatarFileCache.clearCache();
} catch {}
console.log("[LinuxNotification] Service shutdown complete");
}

View File

@@ -121,6 +121,9 @@ export class WcdbCore {
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = 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,12 +2192,26 @@ export class WcdbCore {
const offset = Math.max(0, toInt(options?.offset))
const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240))
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 sessions = (sessionsRes.sessions || [])
const rows = (sessionsRes.sessions || [])
.map((row: any) => ({
sessionId: String(
row.username ||
@@ -2196,9 +2234,22 @@ export class WcdbCore {
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const sessionRows = requestedSessionId
? sessions.filter((row) => row.sessionId === requestedSessionId)
: sessions
this.mediaStreamSessionCache = rows
this.mediaStreamSessionCacheAt = now
return { success: true, rows }
}
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
}
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<Record<string, any>> : []
@@ -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
}
})

View File

@@ -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;
}

View File

@@ -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;

211
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -107,44 +107,6 @@ function App() {
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(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() {
</div>
)}
{/*{showWaylandWarning && (*/}
{/* <div className="agreement-overlay">*/}
{/* <div className="agreement-modal">*/}
{/* <div className="agreement-header">*/}
{/* <Shield size={32} />*/}
{/* <h2>环境兼容性提示 (Wayland)</h2>*/}
{/* </div>*/}
{/* <div className="agreement-content">*/}
{/* <div className="agreement-text">*/}
{/* <p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>*/}
{/* <p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>*/}
{/* <p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>*/}
{/* <br />*/}
{/* <p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>*/}
{/* <p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>*/}
{/* <p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>*/}
{/* </div>*/}
{/* </div>*/}
{/* <div className="agreement-footer">*/}
{/* <div className="agreement-actions">*/}
{/* <button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>*/}
{/* </div>*/}
{/* </div>*/}
{/* </div>*/}
{/* </div>*/}
{/*)}*/}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}

View File

@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
format?: string
avatars?: boolean
dateRange?: ExportDateRangeSelection
fileNamingMode?: configService.ExportFileNamingMode
media?: configService.ExportDefaultMediaConfig
voiceAsText?: boolean
excelCompactColumns?: boolean
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] as const
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
{ 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<HTMLDivElement>(null)
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
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({
}}
/>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
onClick={() => {
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
setShowExportExcelColumnsSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>
<span className="select-value">{exportFileNamingModeLabel}</span>
<ChevronDown size={16} />
</button>
{showExportFileNamingModeSelect && (
<div className="select-dropdown">
{exportFileNamingModeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFileNamingMode(option.value)
await configService.setExportDefaultFileNamingMode(option.value)
onDefaultsChanged?.({ fileNamingMode: option.value })
notify('已更新导出文件命名方式', true)
setShowExportFileNamingModeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label>Excel </label>
@@ -259,6 +319,7 @@ export function ExportDefaultsSettingsForm({
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -1621,6 +1621,7 @@ function ExportPage() {
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
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)

View File

@@ -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<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(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<string, string> = {}
@@ -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<string, string> = {}
const updatePatch: Record<string, boolean> = {}
@@ -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<string> => {
@@ -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,18 +1077,50 @@ function ResourcesPage() {
setBatchBusy(true)
let success = 0
let failed = 0
const previewPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
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
if (!result?.success) {
failed += 1
} else {
success += 1
if (result.localPath) {
const key = getItemKey(item)
@@ -1076,6 +1128,10 @@ function ResourcesPage() {
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)
}

View File

@@ -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 = {}) {
<div className="quote-layout-card-footer">
<div className="quote-layout-card-title-group">
<span className="quote-layout-card-title">{option.label}</span>
<span className="quote-layout-card-desc">{option.description}</span>
</div>
</div>
</button>
@@ -1672,7 +1652,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"><EFBFBD><EFBFBD><EFBFBD></span>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle">
@@ -1696,11 +1676,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group">
<label></label>
<span className="form-hint"></span>
{isWayland && (
<span className="form-hint" style={{ color: '#ff4d4f', marginTop: '4px', display: 'block' }}>
Wayland
</span>
)}
<div className="custom-select">
<div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
@@ -3676,7 +3651,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="updates-hero-main">
<span className="updates-chip"></span>
<h2>{appVersion || '...'}</h2>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更<EFBFBD><EFBFBD><EFBFBD>'}</p>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更'}</p>
</div>
<div className="updates-hero-action">
{updateInfo?.hasUpdate ? (

View File

@@ -31,6 +31,7 @@ const steps = [
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
]
type SetupStepId = typeof steps[number]['id']
interface WelcomePageProps {
standalone?: boolean
@@ -438,6 +439,48 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
}
const jumpToStep = (stepId: SetupStepId) => {
const targetIndex = steps.findIndex(step => step.id === stepId)
if (targetIndex >= 0) setStepIndex(targetIndex)
}
const validateDbStepBeforeNext = async (): Promise<string | null> => {
if (!dbPath) return '数据库目录步骤未完成:请先选择数据库目录'
if (dbPathValidationError) return `数据库目录步骤配置有误:${dbPathValidationError}`
try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
if (!Array.isArray(wxids) || wxids.length === 0) {
return '数据库目录步骤配置有误:当前目录下未找到可用账号数据(缺少 db_storage请重新选择微信数据目录'
}
} catch (e) {
return `数据库目录步骤配置有误:目录读取失败,请确认该路径可访问(${String(e)}`
}
return null
}
const findConfigIssueBeforeConnect = async (): Promise<{ stepId: SetupStepId; message: string } | null> => {
const dbIssue = await validateDbStepBeforeNext()
if (dbIssue) return { stepId: 'db', message: dbIssue }
let scannedWxids: Array<{ wxid: string }> = []
try {
scannedWxids = await window.electronAPI.dbPath.scanWxids(dbPath)
} catch {
scannedWxids = []
}
if (!wxid) {
return { stepId: 'key', message: '解密密钥步骤未完成:请先选择微信账号 (wxid)' }
}
if (!scannedWxids.some(item => item.wxid === wxid)) {
return { stepId: 'key', message: `解密密钥步骤配置有误:微信账号「${wxid}」不在当前数据库目录中,请重新选择账号` }
}
if (!decryptKey || decryptKey.length !== 64) {
return { stepId: 'key', message: '解密密钥步骤未完成:请填写 64 位解密密钥' }
}
return null
}
const canGoNext = () => {
if (currentStep.id === 'intro') return true
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
@@ -453,7 +496,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
return false
}
const handleNext = () => {
const handleNext = async () => {
if (currentStep.id === 'db') {
const dbStepIssue = await validateDbStepBeforeNext()
if (dbStepIssue) {
setError(dbStepIssue)
return
}
}
if (!canGoNext()) {
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
@@ -473,9 +524,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
const handleConnect = async () => {
if (!dbPath) { setError('请先选择数据库目录'); return }
if (!wxid) { setError('请填写微信ID'); return }
if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return }
const configIssue = await findConfigIssueBeforeConnect()
if (configIssue) {
setError(configIssue.message)
jumpToStep(configIssue.stepId)
return
}
setIsConnecting(true)
setError('')
@@ -484,7 +538,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
try {
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
if (!result.success) {
setError(result.error || 'WCDB 连接失败')
const errorMessage = result.error || 'WCDB 连接失败'
if (errorMessage.includes('-3001')) {
const fallbackIssue = await findConfigIssueBeforeConnect()
if (fallbackIssue) {
setError(fallbackIssue.message)
jumpToStep(fallbackIssue.stepId)
} else {
setError(`数据库目录步骤配置有误:${errorMessage}`)
jumpToStep('db')
}
} else {
setError(errorMessage)
}
setLoading(false)
return
}

View File

@@ -30,6 +30,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_FILE_NAMING_MODE: 'exportDefaultFileNamingMode',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
@@ -114,6 +115,8 @@ export interface ExportDefaultMediaConfig {
files: boolean
}
export type ExportFileNamingMode = 'classic' | 'date-range'
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
export type QuoteLayout = 'quote-top' | 'quote-bottom'
export type UpdateChannel = 'stable' | 'preview' | 'dev'
@@ -434,6 +437,18 @@ export async function setExportDefaultDateRange(range: ExportDefaultDateRangeCon
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
}
// 获取导出默认文件命名方式
export async function getExportDefaultFileNamingMode(): Promise<ExportFileNamingMode | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE)
if (value === 'classic' || value === 'date-range') return value
return null
}
// 设置导出默认文件命名方式
export async function setExportDefaultFileNamingMode(mode: ExportFileNamingMode): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE, mode)
}
// 获取导出默认媒体设置
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)

View File

@@ -1,4 +1,10 @@
import { create } from 'zustand'
import {
finishBackgroundTask,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
export interface BatchImageDecryptState {
isBatchDecrypting: boolean
@@ -8,8 +14,9 @@ export interface BatchImageDecryptState {
result: { success: number; fail: number }
startTime: number
sessionName: string
taskId: string | null
startDecrypt: (total: number, sessionName: string) => void
startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void
updateProgress: (current: number, total: number) => void
finishDecrypt: (success: number, fail: number) => void
setShowToast: (show: boolean) => void
@@ -17,7 +24,26 @@ export interface BatchImageDecryptState {
reset: () => void
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({
const clampProgress = (current: number, total: number): { current: number; total: number } => {
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
const normalizedCurrent = normalizedTotal > 0
? Math.min(normalizedCurrentRaw, normalizedTotal)
: normalizedCurrentRaw
return { current: normalizedCurrent, total: normalizedTotal }
}
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
const calcProgressStep = (total: number): number => {
if (total <= 0) return 1
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, get) => ({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
@@ -25,40 +51,127 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) =>
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
taskId: null,
startDecrypt: (total, sessionName) => set({
startDecrypt: (total, sessionName, sourcePage = 'chat') => {
const previousTaskId = get().taskId
if (previousTaskId) {
taskProgressUpdateMeta.delete(previousTaskId)
finishBackgroundTask(previousTaskId, 'canceled', {
detail: '已被新的批量解密任务替换',
progressText: '已替换'
})
}
const normalizedProgress = clampProgress(0, total)
const normalizedSessionName = String(sessionName || '').trim()
const title = normalizedSessionName
? `图片批量解密(${normalizedSessionName}`
: '图片批量解密'
const taskId = registerBackgroundTask({
sourcePage,
title,
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total}`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
cancelable: false
})
taskProgressUpdateMeta.set(taskId, {
lastAt: Date.now(),
lastBucket: 0,
step: calcProgressStep(normalizedProgress.total)
})
set({
isBatchDecrypting: true,
progress: { current: 0, total },
progress: normalizedProgress,
showToast: true,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: Date.now(),
sessionName
}),
sessionName: normalizedSessionName,
taskId
})
},
updateProgress: (current, total) => set({
progress: { current, total }
}),
updateProgress: (current, total) => {
const previousProgress = get().progress
const normalizedProgress = clampProgress(current, total)
const taskId = get().taskId
if (taskId) {
const now = Date.now()
const meta = taskProgressUpdateMeta.get(taskId)
const step = meta?.step || calcProgressStep(normalizedProgress.total)
const bucket = Math.floor(normalizedProgress.current / step)
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
const crossedBucket = !meta || bucket !== meta.lastBucket
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
if (crossedBucket || intervalReached || isFinal) {
updateBackgroundTask(taskId, {
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total}`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
})
taskProgressUpdateMeta.set(taskId, {
lastAt: now,
lastBucket: bucket,
step
})
}
}
if (
previousProgress.current !== normalizedProgress.current ||
previousProgress.total !== normalizedProgress.total
) {
set({
progress: normalizedProgress
})
}
},
finishDecrypt: (success, fail) => set({
finishDecrypt: (success, fail) => {
const taskId = get().taskId
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
const status = normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed'
finishBackgroundTask(taskId, status, {
detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
})
}
set({
isBatchDecrypting: false,
showToast: false,
showResultToast: true,
result: { success, fail },
startTime: 0
}),
result: { success: normalizedSuccess, fail: normalizedFail },
startTime: 0,
taskId: null
})
},
setShowToast: (show) => set({ showToast: show }),
setShowResultToast: (show) => set({ showResultToast: show }),
reset: () => set({
reset: () => {
const taskId = get().taskId
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
finishBackgroundTask(taskId, 'canceled', {
detail: '批量解密任务已重置',
progressText: '已停止'
})
}
set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: ''
sessionName: '',
taskId: null
})
}
}))

View File

@@ -69,7 +69,6 @@ export interface ElectronAPI {
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
checkWayland: () => Promise<boolean>
}
notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
@@ -78,6 +77,7 @@ export interface ElectronAPI {
ready: () => void
resize: (width: number, height: number) => void
onShow: (callback: (event: any, data: any) => void) => () => void
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
}
log: {
getPath: () => Promise<string>
@@ -403,10 +403,16 @@ export interface ElectronAPI {
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean }
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => Promise<{
success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
@@ -414,7 +420,7 @@ export interface ElectronAPI {
}>
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean }
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
@@ -999,6 +1005,7 @@ export interface ExportOptions {
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
txtColumns?: string[]
fileNamingMode?: 'classic' | 'date-range'
sessionLayout?: 'shared' | 'per-session'
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'