Compare commits

..

20 Commits
v4.3.0 ... dev

Author SHA1 Message Date
cc
102eb14b0b Merge pull request #704 from Tosd0/main
fix: 非预期白名单通知行为
2026-04-10 21:08:13 +08:00
cc
e57b9d07f1 Merge pull request #703 from Jasonzhu1207/main
fix:修改了几处中文乱码
2026-04-10 21:07:41 +08:00
Tosd0
3be90d00e5 fix(notification): 系统通知豁免会话白/黑名单过滤 2026-04-10 21:00:28 +08:00
Tosd0
efb5cd3586 fix(notification): 修复白名单为空时过滤器完全失效的问题 2026-04-10 21:00:22 +08:00
Jason
86b1043134 Merge pull request #15 from Jasonzhu1207/chore/sync-upstream-main-20260410
chore: sync upstream main into fork main
2026-04-10 20:51:48 +08:00
Jason
36bed846b2 chore: merge upstream main into fork main 2026-04-10 20:45:04 +08:00
cc
9d3d38fa7e Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-10 20:34:19 +08:00
cc
ddf6b63aec 更新描述文案 2026-04-10 20:34:16 +08:00
Jason
079779c2c6 Merge pull request #14 from Jasonzhu1207/fix/chinese-garbled-text
fix: clean up garbled Chinese text
2026-04-10 20:23:30 +08:00
Jason
afa8bb5fe0 fix: clean up garbled Chinese text 2026-04-10 20:13:46 +08:00
cc
127668ae22 Merge pull request #702 from hicccc77/main
合并
2026-04-10 20:13:18 +08:00
cc
b00264d060 Merge pull request #701 from hicccc77/hicccc77-patch-1
Update README.md
2026-04-10 20:12:53 +08:00
cc
2e135587d4 Update README.md 2026-04-10 20:12:42 +08:00
cc
571bffa923 Merge pull request #700 from hicccc77/dev
更新资源
2026-04-10 20:01:59 +08:00
cc
bc355d43a0 更新资源 2026-04-10 20:01:36 +08:00
cc
e2a207be92 Merge pull request #699 from hicccc77/dev
Dev
2026-04-10 19:46:33 +08:00
cc
397cc888db 尝试修复工作流;修复mac上权限异常的问题 2026-04-10 19:46:11 +08:00
cc
22a2616534 修复密钥问题 2026-04-10 19:23:32 +08:00
cc
d6c9a10766 优化表述与提示;导出文件命名格式优化;启用进程优化 2026-04-09 21:13:13 +08:00
Jason
d96000f0d9 Merge branch 'hicccc77:main' into main 2026-04-07 23:05:17 +08:00
19 changed files with 681 additions and 102 deletions

View File

@@ -60,7 +60,23 @@ jobs:
fi fi
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH" 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')" 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: dev-mac-arm64:
needs: prepare needs: prepare
@@ -81,6 +97,22 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: npm install 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 - name: Set dev version
shell: bash shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
@@ -270,21 +302,25 @@ jobs:
- name: Update fixed dev release notes - name: Update fixed dev release notes
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
shell: bash shell: bash
run: | run: |
set -euo pipefail 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" REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" 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." echo "Release $TAG not found, skip notes update."
exit 0 exit 0
fi fi
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
pick_asset() { pick_asset() {
local pattern="$1" local pattern="$1"
@@ -350,4 +386,22 @@ jobs:
} }
update_release_notes update_release_notes
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
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

@@ -86,7 +86,23 @@ jobs:
fi fi
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH" 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')" 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: preview-mac-arm64:
needs: prepare needs: prepare
@@ -108,6 +124,22 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: npm install 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 - name: Set preview version
shell: bash shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
@@ -315,17 +347,22 @@ jobs:
run: | run: |
set -euo pipefail 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 }}" CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
REPO="$GITHUB_REPOSITORY" REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" 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." echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
exit 0 exit 0
fi fi
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
pick_asset() { pick_asset() {
local pattern="$1" local pattern="$1"
@@ -392,4 +429,22 @@ jobs:
} }
update_release_notes update_release_notes
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
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,6 +31,22 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: npm install 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 - name: Sync version with tag
shell: bash shell: bash
run: | run: |

View File

@@ -1,34 +1,23 @@
# WeFlow # WeFlow
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告 WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
---
<p align="center"> <p align="center">
<img src="app.png" alt="WeFlow" width="90%"> <img src="app.png" alt="WeFlow 应用预览" width="90%">
</p> </p>
---
<p align="center"> <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 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> <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/network/members"> <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>
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks"> <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>
</a> <br><br>
<a href="https://github.com/hicccc77/WeFlow/issues"> <!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues"> <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> <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>
<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>
</p> </p>
> [!TIP] > [!TIP]
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
@@ -47,14 +36,12 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
## 支持平台与设备 ## 支持平台与设备
| 平台 | 设备/架构 | 安装包 | | 平台 | 设备/架构 | 安装包 |
|------|----------|--------| |------|----------|--------|
| Windows | Windows10+、x64amd64 | `.exe` | | Windows | Windows10+、x64amd64 | `.exe` |
| macOS | Apple SiliconM 系列arm64 | `.dmg` | | macOS | Apple SiliconM 系列arm64 | `.dmg` |
| Linux | x64 设备amd64 | `.AppImage``.tar.gz` | | Linux | x64 设备amd64 | `.AppImage``.tar.gz` |
## 快速开始 ## 快速开始
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。 若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
@@ -93,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
完整接口文档:[点击查看](docs/HTTP-API.md) 完整接口文档:[点击查看](docs/HTTP-API.md)
## 面向开发者 ## 面向开发者
如果你想从源码构建或为项目贡献代码,请遵循以下步骤: 如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
@@ -108,9 +94,24 @@ npm install
# 3. 运行应用(开发模式) # 3. 运行应用(开发模式)
npm run dev 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) 为本项目提供了基础框架 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
@@ -120,10 +121,8 @@ npm run dev
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡: 如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6` > TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
## Star History ## Star History
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left"> <a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">

View File

@@ -3455,12 +3455,38 @@ app.whenReady().then(async () => {
} }
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 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, '正在加载配置...') updateSplashProgress(5, '正在加载配置...')
configService = new ConfigService() configService = new ConfigService()
applyAutoUpdateChannel('startup') applyAutoUpdateChannel('startup')
syncLaunchAtStartupPreference() syncLaunchAtStartupPreference()
const onboardingDone = configService.get('onboardingDone') === true
shouldShowMain = onboardingDone
// 将用户主题配置推送给 Splash 窗口 // 将用户主题配置推送给 Splash 窗口
if (splashWindow && !splashWindow.isDestroyed()) { if (splashWindow && !splashWindow.isDestroyed()) {
@@ -3473,7 +3499,7 @@ app.whenReady().then(async () => {
await delay(200) await delay(200)
// 设置资源路径 // 设置资源路径
updateSplashProgress(10, '正在初始化...') updateSplashProgress(12, '正在初始化...')
const candidateResources = app.isPackaged const candidateResources = app.isPackaged
? join(process.resourcesPath, 'resources') ? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources') : join(app.getAppPath(), 'resources')
@@ -3483,13 +3509,13 @@ app.whenReady().then(async () => {
await delay(200) await delay(200)
// 初始化数据库服务 // 初始化数据库服务
updateSplashProgress(18, '正在初始化...') updateSplashProgress(20, '正在初始化...')
wcdbService.setPaths(resourcesPath, userDataPath) wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true) wcdbService.setLogEnabled(configService.get('logEnabled') === true)
await delay(200) await delay(200)
// 注册 IPC 处理器 // 注册 IPC 处理器
updateSplashProgress(25, '正在初始化...') updateSplashProgress(28, '正在初始化...')
registerIpcHandlers() registerIpcHandlers()
chatService.addDbMonitorListener((type, json) => { chatService.addDbMonitorListener((type, json) => {
messagePushService.handleDbMonitorChange(type, json) messagePushService.handleDbMonitorChange(type, json)
@@ -3499,12 +3525,54 @@ app.whenReady().then(async () => {
insightService.start() insightService.start()
await delay(200) await delay(200)
// 检查配置状态 // 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
const onboardingDone = configService.get('onboardingDone') if (onboardingDone) {
shouldShowMain = onboardingDone === true 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 }) mainWindow = createWindow({ autoShow: false })
let iconName = 'icon.ico'; let iconName = 'icon.ico';
@@ -3576,7 +3644,7 @@ app.whenReady().then(async () => {
) )
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点) // 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
updateSplashProgress(30, '正在加载界面...', true) updateSplashProgress(70, '正在准备主窗口...', true)
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (mainWindowReady) { if (mainWindowReady) {
resolve() resolve()

View File

@@ -323,6 +323,8 @@ class ChatService {
private contactLabelNameMapCacheAt = 0 private contactLabelNameMapCacheAt = 0
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000 private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null 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 contactDisplayNameCollator = new Intl.Collator('zh-CN')
private readonly slowGetContactsLogThresholdMs = 1200 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 }> { private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
if (this.connected && wcdbService.isReady()) { if (this.connected && wcdbService.isReady()) {
return { success: true } 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 }> { private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
const isLiteMode = options?.lite === true 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 startedAt = Date.now()
const stageDurations: Array<{ stage: string; ms: number }> = [] const stageDurations: Array<{ stage: string; ms: number }> = []
const captureStage = (stage: string, stageStartedAt: number) => { const captureStage = (stage: string, stageStartedAt: number) => {
@@ -1487,6 +1568,10 @@ class ChatService {
.join(', ') .join(', ')
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`) 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 } return { success: true, contacts: result }
} catch (e) { } catch (e) {
console.error('ChatService: 获取通讯录失败:', e) console.error('ChatService: 获取通讯录失败:', e)
@@ -2886,6 +2971,7 @@ class ChatService {
this.sessionTablesCache.clear() this.sessionTablesCache.clear()
this.messageTableColumnsCache.clear() this.messageTableColumnsCache.clear()
this.messageDbCountSnapshotCache = null this.messageDbCountSnapshotCache = null
this.contactsMemoryCache.clear()
this.refreshSessionStatsCacheScope(scope) this.refreshSessionStatsCacheScope(scope)
this.refreshGroupMyMessageCountCacheScope(scope) this.refreshGroupMyMessageCountCacheScope(scope)
} }
@@ -5983,6 +6069,7 @@ class ChatService {
if (includeContacts) { if (includeContacts) {
this.avatarCache.clear() this.avatarCache.clear()
this.contactCacheService.clear() this.contactCacheService.clear()
this.contactsMemoryCache.clear()
} }
if (includeMessages) { if (includeMessages) {

View File

@@ -270,7 +270,9 @@ export class ConfigService {
const inLockMode = this.isLockMode() && this.unlockPassword const inLockMode = this.isLockMode() && this.unlockPassword
if (ENCRYPTED_BOOL_KEYS.has(key)) { 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)) { } else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) { if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K] toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
@@ -649,7 +651,7 @@ export class ConfigService {
clearHelloSecret(): void { clearHelloSecret(): void {
this.store.set('authHelloSecret', '' as any) 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 加密格式 // 将旧版明文 auth 字段迁移为 safeStorage 加密格式
// 如果已经是 safe: 或 lock: 前缀则跳过 // 如果已经是 safe: 或 lock: 前缀则跳过
const rawEnabled: any = this.store.get('authEnabled') const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'boolean') { if (rawEnabled === true || rawEnabled === 'true') {
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any) 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') const rawUseHello: any = this.store.get('authUseHello')
if (typeof rawUseHello === 'boolean') { if (rawUseHello === true || rawUseHello === 'true') {
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any) 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') const rawPassword: any = this.store.get('authPassword')
@@ -729,7 +736,7 @@ export class ConfigService {
// === 工具方法 === // === 工具方法 ===
/** /**
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局<EFBFBD><EFBFBD> * 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局
*/ */
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } { getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
const wxid = this.get('myWxid') const wxid = this.get('myWxid')

View File

@@ -92,6 +92,7 @@ export interface ExportOptions {
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null
senderUsername?: string senderUsername?: string
fileNameSuffix?: string fileNameSuffix?: string
fileNamingMode?: 'classic' | 'date-range'
exportMedia?: boolean exportMedia?: boolean
exportAvatars?: boolean exportAvatars?: boolean
exportImages?: 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 { private isCloneUnsupportedError(code: string | undefined): boolean {
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY' return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
} }
@@ -8911,6 +8986,7 @@ class ExportService {
? path.join(outputDir, 'texts') ? path.join(outputDir, 'texts')
: outputDir : outputDir
const createdTaskDirs = new Set<string>() const createdTaskDirs = new Set<string>()
const reservedOutputPaths = new Set<string>()
const ensureTaskDir = async (dirPath: string) => { const ensureTaskDir = async (dirPath: string) => {
if (createdTaskDirs.has(dirPath)) return if (createdTaskDirs.has(dirPath)) return
await fs.promises.mkdir(dirPath, { recursive: true }) await fs.promises.mkdir(dirPath, { recursive: true })
@@ -9159,10 +9235,8 @@ class ExportService {
phaseLabel: '准备导出' phaseLabel: '准备导出'
}) })
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode)
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '')
const safeName = suffix ? `${baseName}_${suffix}` : baseName
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : '' const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : ''
const fileNameWithPrefix = `${sessionTypePrefix}${safeName}` const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
@@ -9180,13 +9254,13 @@ class ExportService {
else if (effectiveOptions.format === 'txt') ext = '.txt' else if (effectiveOptions.format === 'txt') ext = '.txt'
else if (effectiveOptions.format === 'weclone') ext = '.csv' else if (effectiveOptions.format === 'weclone') ext = '.csv'
else if (effectiveOptions.format === 'html') ext = '.html' else if (effectiveOptions.format === 'html') ext = '.html'
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
const canTrySkipUnchanged = canTrySkipUnchangedTextSessions && const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
typeof messageCountHint === 'number' && typeof messageCountHint === 'number' &&
messageCountHint >= 0 && messageCountHint >= 0 &&
typeof latestTimestampHint === 'number' && typeof latestTimestampHint === 'number' &&
latestTimestampHint > 0 && latestTimestampHint > 0 &&
await this.pathExists(outputPath) await this.pathExists(preferredOutputPath)
if (canTrySkipUnchanged) { if (canTrySkipUnchanged) {
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format) const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
const hasNoDataChange = Boolean( 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 } let result: { success: boolean; error?: string }
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') { if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control) result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)

View File

@@ -949,7 +949,7 @@ export class ImageDecryptService {
} catch { } } catch { }
} }
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 --- // --- 策略 B: 新版 Session 哈希路径猜测 ---
try { try {
const entries = await fs.readdir(root, { withFileTypes: true }) const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries const sessionDirs = entries
@@ -1854,7 +1854,7 @@ export class ImageDecryptService {
} }
/** /**
* 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦 * wxgf 数据中提取 HEVC NALU 裸流
*/ */
private extractHevcNalu(buffer: Buffer): Buffer | null { private extractHevcNalu(buffer: Buffer): Buffer | null {
const nalUnits: Buffer[] = [] const nalUnits: Buffer[] = []

View File

@@ -316,7 +316,7 @@ class InsightService {
} }
/** /**
* 测<EFBFBD><EFBFBD><EFBFBD> API 连接,返回 { success, message }。 * 测 API 连接,返回 { success, message }。
* 供设置页"测试连接"按钮调用。 * 供设置页"测试连接"按钮调用。
*/ */
async testConnection(): Promise<{ success: boolean; message: string }> { async testConnection(): Promise<{ success: boolean; message: string }> {
@@ -475,7 +475,7 @@ class InsightService {
} }
/** /**
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模<EFBFBD><EFBFBD><EFBFBD>全局上下文。 * 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模全局上下文。
*/ */
private getTodayTotalTriggerCount(): number { private getTodayTotalTriggerCount(): number {
this.resetIfNewDay() this.resetIfNewDay()
@@ -709,7 +709,7 @@ class InsightService {
return return
} }
// ── 构建 prompt ─────────────<EFBFBD><EFBFBD><EFBFBD>───────────────────────────────<EFBFBD><EFBFBD><EFBFBD>──────────── // ── 构建 prompt ────────────────────────────────────────────────────────────
// 今日触发统计(让模型具备时间与克制感) // 今日触发统计(让模型具备时间与克制感)
const sessionTriggerTimes = this.recordTrigger(sessionId) const sessionTriggerTimes = this.recordTrigger(sessionId)

View File

@@ -1,6 +1,6 @@
import { app, shell } from 'electron' import { app, shell } from 'electron'
import { join, basename, dirname } from 'path' 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 { execFile, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import crypto from 'crypto' import crypto from 'crypto'
@@ -403,19 +403,71 @@ export class KeyServiceMac {
return `'${String(text).replace(/'/g, `'\\''`)}'` 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( private async getDbKeyByHelperElevated(
timeoutMs: number, timeoutMs: number,
onStatus?: (message: string, level: number) => void onStatus?: (message: string, level: number) => void
): Promise<string> { ): Promise<string> {
const helperPath = this.getHelperPath() const helperPath = this.getHelperPath()
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
this.ensureExecutableBitsBestEffort(artifactPaths)
const waitMs = Math.max(timeoutMs, 30_000) const waitMs = Math.max(timeoutMs, 30_000)
const timeoutSec = Math.ceil(waitMs / 1000) + 30 const timeoutSec = Math.ceil(waitMs / 1000) + 30
const pid = await this.getWeChatPid() 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 拼接导致整条失败 // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
// 通过 try/on error 回传详细错误,避免只看到 "Command failed" // 通过 try/on error 回传详细错误,避免只看到 "Command failed"
const scriptLines = [ const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`, `set cmd to ${JSON.stringify(privilegedCmd)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
`set timeoutSec to ${timeoutSec}`, `set timeoutSec to ${timeoutSec}`,
'try', 'try',
'with timeout of timeoutSec seconds', 'with timeout of timeoutSec seconds',
@@ -751,10 +803,12 @@ export class KeyServiceMac {
try { try {
const helperPath = this.getImageScanHelperPath() const helperPath = this.getImageScanHelperPath()
const ciphertextHex = ciphertext.toString('hex') const ciphertextHex = ciphertext.toString('hex')
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
this.ensureExecutableBitsBestEffort(artifactPaths)
// 1) 直接运行 helper有正式签名的 debugger entitlement 时可用) // 1) 直接运行 helper有正式签名的 debugger entitlement 时可用)
if (!this._needsElevation) { 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.key) return direct.key
if (direct.permissionError) { if (direct.permissionError) {
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式') console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
@@ -765,7 +819,12 @@ export class KeyServiceMac {
// 2) 通过 osascript 以管理员权限运行 helperSIP 下 ad-hoc 签名无法获取 task_for_pid // 2) 通过 osascript 以管理员权限运行 helperSIP 下 ad-hoc 签名无法获取 task_for_pid
if (this._needsElevation) { 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 if (elevated.key) return elevated.key
} }
} catch (e: any) { } catch (e: any) {
@@ -868,12 +927,19 @@ export class KeyServiceMac {
} }
private _spawnScanHelper( 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 }> { ): Promise<{ key: string | null; permissionError: boolean }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let child: ReturnType<typeof spawn> let child: ReturnType<typeof spawn>
if (elevated) { 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`], child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
{ stdio: ['ignore', 'pipe', 'pipe'] }) { stdio: ['ignore', 'pipe', 'pipe'] })
} else { } else {

View File

@@ -115,12 +115,14 @@ export async function showNotification(data: any) {
// 检查会话过滤 // 检查会话过滤
const filterMode = config.get("notificationFilterMode") || "all"; const filterMode = config.get("notificationFilterMode") || "all";
const filterList = config.get("notificationFilterList") || []; const filterList = config.get("notificationFilterList") || [];
const sessionId = data.sessionId; const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
const isSystemNotification = sessionId.startsWith("weflow-");
if (sessionId && filterMode !== "all" && filterList.length > 0) { if (!isSystemNotification && filterMode !== "all") {
const isInList = filterList.includes(sessionId); const isInList = sessionId !== "" && filterList.includes(sessionId);
if (filterMode === "whitelist" && !isInList) { if (filterMode === "whitelist" && !isInList) {
// 白名单模式:不在列表中则不显示 // 白名单模式:不在列表中则不显示(空列表视为全部拦截)
return; return;
} }
if (filterMode === "blacklist" && isInList) { if (filterMode === "blacklist" && isInList) {

View File

@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
format?: string format?: string
avatars?: boolean avatars?: boolean
dateRange?: ExportDateRangeSelection dateRange?: ExportDateRangeSelection
fileNamingMode?: configService.ExportFileNamingMode
media?: configService.ExportDefaultMediaConfig media?: configService.ExportDefaultMediaConfig
voiceAsText?: boolean voiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] as const ] 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 exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => { const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
@@ -56,12 +62,15 @@ export function ExportDefaultsSettingsForm({
layout = 'stacked' layout = 'stacked'
}: ExportDefaultsSettingsFormProps) { }: ExportDefaultsSettingsFormProps) {
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null) const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection()) const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({ const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true, images: true,
videos: true, videos: true,
@@ -76,10 +85,11 @@ export function ExportDefaultsSettingsForm({
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
void (async () => { 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.getExportDefaultFormat(),
configService.getExportDefaultAvatars(), configService.getExportDefaultAvatars(),
configService.getExportDefaultDateRange(), configService.getExportDefaultDateRange(),
configService.getExportDefaultFileNamingMode(),
configService.getExportDefaultMedia(), configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(), configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultExcelCompactColumns(),
@@ -91,6 +101,7 @@ export function ExportDefaultsSettingsForm({
setExportDefaultFormat(savedFormat || 'excel') setExportDefaultFormat(savedFormat || 'excel')
setExportDefaultAvatars(savedAvatars ?? true) setExportDefaultAvatars(savedAvatars ?? true)
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setExportDefaultMedia(savedMedia ?? { setExportDefaultMedia(savedMedia ?? {
images: true, images: true,
videos: true, videos: true,
@@ -114,15 +125,19 @@ export function ExportDefaultsSettingsForm({
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false) setShowExportExcelColumnsSelect(false)
} }
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
setShowExportFileNamingModeSelect(false)
}
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportExcelColumnsSelect]) }, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
const notify = (text: string, success = true) => { const notify = (text: string, success = true) => {
onNotify?.(text, success) onNotify?.(text, success)
@@ -224,6 +239,7 @@ export function ExportDefaultsSettingsForm({
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`} className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
onClick={() => { onClick={() => {
setShowExportExcelColumnsSelect(false) setShowExportExcelColumnsSelect(false)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(true) 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-group">
<div className="form-copy"> <div className="form-copy">
<label>Excel </label> <label>Excel </label>
@@ -259,6 +319,7 @@ export function ExportDefaultsSettingsForm({
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`} className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => { onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(false) setIsExportDateRangeDialogOpen(false)
}} }}
> >

View File

@@ -1621,6 +1621,7 @@ function ExportPage() {
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel') const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection()) const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({ const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true, images: true,
videos: true, videos: true,
@@ -2270,7 +2271,7 @@ function ExportPage() {
setIsBaseConfigLoading(true) setIsBaseConfigLoading(true)
let isReady = true let isReady = true
try { 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.getExportPath(),
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(), configService.getExportDefaultAvatars(),
@@ -2287,6 +2288,7 @@ function ExportPage() {
configService.getExportWriteLayout(), configService.getExportWriteLayout(),
configService.getExportSessionNamePrefixEnabled(), configService.getExportSessionNamePrefixEnabled(),
configService.getExportDefaultDateRange(), configService.getExportDefaultDateRange(),
configService.getExportDefaultFileNamingMode(),
ensureExportCacheScope() ensureExportCacheScope()
]) ])
@@ -2318,6 +2320,7 @@ function ExportPage() {
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true) setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
setTimeRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange)
@@ -4397,6 +4400,7 @@ function ExportPage() {
displayNamePreference: options.displayNamePreference, displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency, exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
fileNamingMode: exportDefaultFileNamingMode,
sessionLayout, sessionLayout,
sessionNameWithTypePrefix, sessionNameWithTypePrefix,
dateRange: options.useAllTime dateRange: options.useAllTime
@@ -7089,6 +7093,9 @@ function ExportPage() {
if (patch.dateRange) { if (patch.dateRange) {
setExportDefaultDateRangeSelection(patch.dateRange) setExportDefaultDateRangeSelection(patch.dateRange)
} }
if (patch.fileNamingMode) {
setExportDefaultFileNamingMode(patch.fileNamingMode)
}
if (patch.media) { if (patch.media) {
const mediaPatch = patch.media const mediaPatch = patch.media
setExportDefaultMedia(mediaPatch) setExportDefaultMedia(mediaPatch)

View File

@@ -125,7 +125,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
setHttpApiToken(token) setHttpApiToken(token)
await configService.setHttpApiToken(token) await configService.setHttpApiToken(token)
showMessage('已生成<EFBFBD><EFBFBD>保存新的 Access Token', true) showMessage('已生成保存新的 Access Token', true)
} }
const clearApiToken = async () => { const clearApiToken = async () => {
@@ -618,7 +618,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true) showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
await handleCheckUpdate() await handleCheckUpdate()
} catch (e: any) { } catch (e: any) {
showMessage(`切换更新渠道<EFBFBD><EFBFBD>败: ${e}`, false) showMessage(`切换更新渠道败: ${e}`, false)
} }
} }
@@ -1213,7 +1213,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
if (result.success && result.aesKey) { if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey) setImageAesKey(result.aesKey)
setImageKeyStatus('已获取图片<EFBFBD><EFBFBD>钥') setImageKeyStatus('已获取图片钥')
showMessage('已自动获取图片密钥', true) showMessage('已自动获取图片密钥', true)
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
const newAesKey = result.aesKey const newAesKey = result.aesKey
@@ -1457,13 +1457,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{ {
value: 'quote-top' as const, value: 'quote-top' as const,
label: '引用在上', label: '引用在上',
description: '更接近当前 WeFlow 风格',
successMessage: '已切换为引用在上样式' successMessage: '已切换为引用在上样式'
}, },
{ {
value: 'quote-bottom' as const, value: 'quote-bottom' as const,
label: '正文在上', label: '正文在上',
description: '更接近微信 / 密语风格',
successMessage: '已切换为正文在上样式' successMessage: '已切换为正文在上样式'
} }
].map(option => { ].map(option => {
@@ -1513,7 +1511,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="quote-layout-card-footer"> <div className="quote-layout-card-footer">
<div className="quote-layout-card-title-group"> <div className="quote-layout-card-title-group">
<span className="quote-layout-card-title">{option.label}</span> <span className="quote-layout-card-title">{option.label}</span>
<span className="quote-layout-card-desc">{option.description}</span>
</div> </div>
</div> </div>
</button> </button>
@@ -2875,7 +2872,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
type="password" type="password"
className="field-input" className="field-input"
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw" placeholder="在此处填入你的 Telegram Bot Token"
value={aiInsightTelegramToken} value={aiInsightTelegramToken}
onChange={(e) => { onChange={(e) => {
const val = e.target.value const val = e.target.value
@@ -3210,7 +3207,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
value={`http://${httpApiHost}:${httpApiPort}`} value={`http://${httpApiHost}:${httpApiPort}`}
readOnly readOnly
/> />
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复<EFBFBD><EFBFBD><EFBFBD>"> <button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复">
<Copy size={16} /> <Copy size={16} />
</button> </button>
</div> </div>
@@ -3344,7 +3341,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
try { try {
const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello') const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
if (!verifyResult.success) { if (!verifyResult.success) {
showMessage(verifyResult.error || 'Windows Hello <EFBFBD><EFBFBD>证失败', false) showMessage(verifyResult.error || 'Windows Hello 证失败', false)
return return
} }
@@ -3576,7 +3573,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
onClick={handleSetupHello} onClick={handleSetupHello}
disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword} disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
> >
{isSettingHello ? '<EFBFBD><EFBFBD><EFBFBD>置中...' : '开启与设置'} {isSettingHello ? '置中...' : '开启与设置'}
</button> </button>
)} )}
</div> </div>

View File

@@ -1127,7 +1127,7 @@ export default function SnsPage() {
activeContactsCountTaskIdRef.current = null activeContactsCountTaskIdRef.current = null
} }
finishBackgroundTask(taskId, 'completed', { finishBackgroundTask(taskId, 'completed', {
detail: '鑱旂郴浜烘湅鍙嬪湀鏉℃暟琛ョ畻瀹屾垚', detail: '联系人朋友圈条数补算完成',
progressText: `${totalTargets}/${totalTargets}` progressText: `${totalTargets}/${totalTargets}`
}) })
} }

View File

@@ -31,6 +31,7 @@ const steps = [
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' }, { id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
{ id: 'security', title: '安全防护', desc: '保护你的数据' } { id: 'security', title: '安全防护', desc: '保护你的数据' }
] ]
type SetupStepId = typeof steps[number]['id']
interface WelcomePageProps { interface WelcomePageProps {
standalone?: boolean 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 = () => { const canGoNext = () => {
if (currentStep.id === 'intro') return true if (currentStep.id === 'intro') return true
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
@@ -453,7 +496,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
return false return false
} }
const handleNext = () => { const handleNext = async () => {
if (currentStep.id === 'db') {
const dbStepIssue = await validateDbStepBeforeNext()
if (dbStepIssue) {
setError(dbStepIssue)
return
}
}
if (!canGoNext()) { if (!canGoNext()) {
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录') if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError) else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
@@ -473,9 +524,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
const handleConnect = async () => { const handleConnect = async () => {
if (!dbPath) { setError('请先选择数据库目录'); return } const configIssue = await findConfigIssueBeforeConnect()
if (!wxid) { setError('请填写微信ID'); return } if (configIssue) {
if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return } setError(configIssue.message)
jumpToStep(configIssue.stepId)
return
}
setIsConnecting(true) setIsConnecting(true)
setError('') setError('')
@@ -484,7 +538,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
try { try {
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
if (!result.success) { 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) setLoading(false)
return return
} }

View File

@@ -30,6 +30,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat', EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars', EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_FILE_NAMING_MODE: 'exportDefaultFileNamingMode',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
@@ -114,6 +115,8 @@ export interface ExportDefaultMediaConfig {
files: boolean files: boolean
} }
export type ExportFileNamingMode = 'classic' | 'date-range'
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
export type QuoteLayout = 'quote-top' | 'quote-bottom' export type QuoteLayout = 'quote-top' | 'quote-bottom'
export type UpdateChannel = 'stable' | 'preview' | 'dev' 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) 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> { export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA) const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
@@ -505,7 +520,7 @@ export async function setExportDefaultTxtColumns(columns: string[]): Promise<voi
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns) await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
} }
// 获取导出默认并发<EFBFBD><EFBFBD> // 获取导出默认并发
export async function getExportDefaultConcurrency(): Promise<number | null> { export async function getExportDefaultConcurrency(): Promise<number | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY) const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY)
if (typeof value === 'number' && Number.isFinite(value)) return value if (typeof value === 'number' && Number.isFinite(value)) return value

View File

@@ -1005,6 +1005,7 @@ export interface ExportOptions {
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[] txtColumns?: string[]
fileNamingMode?: 'classic' | 'date-range'
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
sessionNameWithTypePrefix?: boolean sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname' displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'