Merge pull request #15 from Jasonzhu1207/chore/sync-upstream-main-20260410

chore: sync upstream main into fork main
This commit is contained in:
Jason
2026-04-10 20:51:48 +08:00
committed by GitHub
62 changed files with 1693 additions and 901 deletions

View File

@@ -6,6 +6,10 @@ on:
- cron: "0 16 * * *" - cron: "0 16 * * *"
workflow_dispatch: workflow_dispatch:
concurrency:
group: dev-nightly-fixed-release
cancel-in-progress: true
permissions: permissions:
contents: write contents: write
@@ -56,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
@@ -77,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
@@ -266,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"
@@ -329,9 +369,39 @@ jobs:
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态 - 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
EOF 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')" RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
jq -n --rawfile body dev_release_notes.md \ RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
'{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \ settled="false"
> release_update_payload.json for i in 1 2 3 4 5; do
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url 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 * * *" - cron: "0 16 * * *"
workflow_dispatch: workflow_dispatch:
concurrency:
group: preview-nightly-fixed-release
cancel-in-progress: true
permissions: permissions:
contents: write contents: write
@@ -82,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
@@ -104,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
@@ -311,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"
@@ -371,9 +412,39 @@ jobs:
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源 > 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
EOF 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')" RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
jq -n --rawfile body preview_release_notes.md \ RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
'{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \ settled="false"
> release_update_payload.json for i in 1 2 3 4 5; do
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url 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 - 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: |
VERSION=${GITHUB_REF_NAME#v} VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION" 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 - name: Build Frontend & Type Check
shell: bash shell: bash
@@ -93,7 +109,7 @@ jobs:
run: | run: |
VERSION=${GITHUB_REF_NAME#v} VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION" 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 - name: Build Frontend & Type Check
shell: bash shell: bash
@@ -115,7 +131,7 @@ jobs:
TAG=${GITHUB_REF_NAME} TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }} REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7" 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 if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
@@ -144,7 +160,7 @@ jobs:
run: | run: |
VERSION=${GITHUB_REF_NAME#v} VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION" 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 - name: Build Frontend & Type Check
shell: bash shell: bash
@@ -166,7 +182,7 @@ jobs:
TAG=${GITHUB_REF_NAME} TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }} REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7" 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 if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
@@ -195,7 +211,7 @@ jobs:
run: | run: |
VERSION=${GITHUB_REF_NAME#v} VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION" 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 - name: Build Frontend & Type Check
shell: bash shell: bash
@@ -217,7 +233,7 @@ jobs:
TAG=${GITHUB_REF_NAME} TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }} REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7" 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 if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber

View File

@@ -1,5 +1,8 @@
name: Security Scan name: Security Scan
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on: on:
schedule: schedule:
- cron: '0 2 * * *' # 每天 UTC 02:00 - cron: '0 2 * * *' # 每天 UTC 02:00
@@ -24,15 +27,15 @@ jobs:
steps: steps:
- name: Checkout ${{ matrix.branch }} - name: Checkout ${{ matrix.branch }}
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
ref: ${{ matrix.branch }} ref: ${{ matrix.branch }}
fetch-depth: 0 fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: '20' node-version: '24'
cache: 'npm' # 使用 npm 缓存加速 cache: 'npm' # 使用 npm 缓存加速
- name: Install dependencies - name: Install dependencies
@@ -71,10 +74,16 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 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 - name: Run npm audit on all branches
run: | run: |
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do 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 *.aps
wcdb/ wcdb/
!resources/wcdb/
!resources/wcdb/**
xkey/ xkey/
server/ server/
*info *info

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,18 +121,16 @@ 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">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" /> <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" /> <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" /> <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
</picture> </picture>
</a> </a>
<div align="center"> <div align="center">

View File

@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
function stripDatVariantSuffix(base: string): string { function stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase() 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) { for (const suffix of suffixes) {
if (lower.endsWith(suffix)) { if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length) return lower.slice(0, -suffix.length)
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
const lower = fileName.toLowerCase() const lower = fileName.toLowerCase()
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600 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 (!hasXVariant(baseLower)) return 500
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
if (isThumbnailDat(lower)) return 100 if (isThumbnailDat(lower)) return 100
return 350 return 350

View File

@@ -182,7 +182,6 @@ const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
autoUpdater.channel = nextUpdaterChannel autoUpdater.channel = nextUpdaterChannel
lastAppliedUpdaterChannel = nextUpdaterChannel lastAppliedUpdaterChannel = nextUpdaterChannel
lastAppliedUpdaterFeedUrl = nextFeedUrl lastAppliedUpdaterFeedUrl = nextFeedUrl
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel}feed=${nextFeedUrl}allowDowngrade=${autoUpdater.allowDowngrade}`)
} }
applyAutoUpdateChannel('startup') applyAutoUpdateChannel('startup')
@@ -1619,6 +1618,7 @@ function registerIpcHandlers() {
applyAutoUpdateChannel('settings') applyAutoUpdateChannel('settings')
} }
void messagePushService.handleConfigChanged(key) void messagePushService.handleConfigChanged(key)
void insightService.handleConfigChanged(key)
return result return result
}) })
@@ -1644,6 +1644,7 @@ function registerIpcHandlers() {
} }
configService?.clear() configService?.clear()
messagePushService.handleConfigCleared() messagePushService.handleConfigCleared()
insightService.handleConfigCleared()
return true return true
}) })
@@ -1692,13 +1693,6 @@ function registerIpcHandlers() {
return applyLaunchAtStartupPreference(enabled === true) 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 () => { ipcMain.handle('log:getPath', async () => {
return join(app.getPath('userData'), 'logs', 'wcdb.log') 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 }) => { ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload) 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) return imageDecryptService.resolveCachedImage(payload)
}) })
ipcMain.handle( ipcMain.handle(
@@ -2580,13 +2580,14 @@ function registerIpcHandlers() {
async ( async (
_, _,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => { ) => {
const list = Array.isArray(payloads) ? payloads : [] const list = Array.isArray(payloads) ? payloads : []
const rows = await Promise.all(list.map(async (payload) => { const rows = await Promise.all(list.map(async (payload) => {
return imageDecryptService.resolveCachedImage({ return imageDecryptService.resolveCachedImage({
...payload, ...payload,
disableUpdateCheck: options?.disableUpdateCheck === true disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false
}) })
})) }))
return { success: true, rows } return { success: true, rows }
@@ -2597,7 +2598,7 @@ function registerIpcHandlers() {
async ( async (
_, _,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => { ) => {
imagePreloadService.enqueue(payloads || [], options) imagePreloadService.enqueue(payloads || [], options)
return true return true
@@ -3454,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()) {
@@ -3472,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')
@@ -3482,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)
@@ -3498,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';
@@ -3575,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

@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
onShow: (callback: (event: any, data: any) => void) => { onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback) ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show') 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)) ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
return () => ipcRenderer.removeAllListeners('app:updateAvailable') return () => ipcRenderer.removeAllListeners('app:updateAvailable')
}, },
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
}, },
// 日志 // 日志
@@ -266,15 +270,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
ipcRenderer.invoke('image:decrypt', payload), 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), ipcRenderer.invoke('image:resolveCache', payload),
resolveCacheBatch: ( resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options), ) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: ( preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options), ) => ipcRenderer.invoke('image:preload', payloads, options),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload) 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 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')

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

@@ -63,6 +63,7 @@ type CachedImagePayload = {
imageDatName?: string imageDatName?: string
preferFilePath?: boolean preferFilePath?: boolean
disableUpdateCheck?: boolean disableUpdateCheck?: boolean
allowCacheIndex?: boolean
} }
type DecryptImagePayload = CachedImagePayload & { type DecryptImagePayload = CachedImagePayload & {
@@ -116,7 +117,9 @@ export class ImageDecryptService {
} }
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> { async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
await this.ensureCacheIndexed() if (payload.allowCacheIndex !== false) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload) const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0] const cacheKey = cacheKeys[0]
if (!cacheKey) { if (!cacheKey) {
@@ -673,41 +676,53 @@ export class ImageDecryptService {
return null return null
} }
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) const searchNames = Array.from(
if (!allowThumbnail) { new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
return null )
} if (searchNames.length === 0) return null
if (!imageDatName) return null
if (!skipResolvedCache) { if (!skipResolvedCache) {
const cached = this.resolvedCache.get(imageDatName) for (const searchName of searchNames) {
if (cached && existsSync(cached)) { const cached = this.resolvedCache.get(searchName)
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail) if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
// 缓存的是缩略图,尝试找高清图 if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
const hdPath = this.findHdVariantInSameDir(preferred) // 缓存的是缩略图,尝试找高清图
if (hdPath) return hdPath const hdPath = this.findHdVariantInSameDir(preferred)
if (hdPath) return hdPath
}
} }
} }
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail) for (const searchName of searchNames) {
if (datPath) { const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath }) if (datPath) {
this.resolvedCache.set(imageDatName, datPath) this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
this.cacheDatPath(accountDir, imageDatName, datPath) if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
return datPath if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
} this.cacheDatPath(accountDir, searchName, datPath)
const normalized = this.normalizeDatBase(imageDatName) if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
if (normalized !== imageDatName.toLowerCase()) { if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) return datPath
if (normalizedPath) {
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath })
this.resolvedCache.set(imageDatName, normalizedPath)
this.cacheDatPath(accountDir, imageDatName, normalizedPath)
return normalizedPath
} }
} }
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized })
for (const searchName of searchNames) {
const normalized = this.normalizeDatBase(searchName)
if (normalized !== searchName.toLowerCase()) {
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
if (normalizedPath) {
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath })
if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath)
if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath)
this.cacheDatPath(accountDir, searchName, normalizedPath)
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath)
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath)
return normalizedPath
}
}
}
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
return null return null
} }
@@ -1042,7 +1057,7 @@ export class ImageDecryptService {
private stripDatVariantSuffix(base: string): string { private stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase() 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) { for (const suffix of suffixes) {
if (lower.endsWith(suffix)) { if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length) return lower.slice(0, -suffix.length)
@@ -1058,8 +1073,10 @@ export class ImageDecryptService {
const lower = name.toLowerCase() const lower = name.toLowerCase()
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower 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('_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 (!this.hasXVariant(baseLower)) return 500
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400 if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
if (this.isThumbnailDat(lower)) return 100 if (this.isThumbnailDat(lower)) return 100
return 350 return 350
@@ -1070,9 +1087,13 @@ export class ImageDecryptService {
const names = [ const names = [
`${baseName}_h.dat`, `${baseName}_h.dat`,
`${baseName}.h.dat`, `${baseName}.h.dat`,
`${baseName}.dat`,
`${baseName}_hd.dat`, `${baseName}_hd.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`,
`${baseName}.c.dat` `${baseName}.c.dat`
] ]

View File

@@ -8,11 +8,13 @@ type PreloadImagePayload = {
type PreloadOptions = { type PreloadOptions = {
allowDecrypt?: boolean allowDecrypt?: boolean
allowCacheIndex?: boolean
} }
type PreloadTask = PreloadImagePayload & { type PreloadTask = PreloadImagePayload & {
key: string key: string
allowDecrypt: boolean allowDecrypt: boolean
allowCacheIndex: boolean
} }
export class ImagePreloadService { export class ImagePreloadService {
@@ -27,6 +29,7 @@ export class ImagePreloadService {
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void { enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
if (!Array.isArray(payloads) || payloads.length === 0) return if (!Array.isArray(payloads) || payloads.length === 0) return
const allowDecrypt = options?.allowDecrypt !== false const allowDecrypt = options?.allowDecrypt !== false
const allowCacheIndex = options?.allowCacheIndex !== false
for (const payload of payloads) { for (const payload of payloads) {
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
const cacheKey = payload.imageMd5 || payload.imageDatName const cacheKey = payload.imageMd5 || payload.imageDatName
@@ -34,7 +37,7 @@ export class ImagePreloadService {
const key = `${payload.sessionId || 'unknown'}|${cacheKey}` const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue if (this.pending.has(key)) continue
this.pending.add(key) this.pending.add(key)
this.queue.push({ ...payload, key, allowDecrypt }) this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
} }
this.processQueue() this.processQueue()
} }
@@ -71,7 +74,8 @@ export class ImagePreloadService {
sessionId: task.sessionId, sessionId: task.sessionId,
imageMd5: task.imageMd5, imageMd5: task.imageMd5,
imageDatName: task.imageDatName, imageDatName: task.imageDatName,
disableUpdateCheck: !task.allowDecrypt disableUpdateCheck: !task.allowDecrypt,
allowCacheIndex: task.allowCacheIndex
}) })
if (cached.success) return if (cached.success) return
if (!task.allowDecrypt) return if (!task.allowDecrypt) return

View File

@@ -15,10 +15,8 @@
import https from 'https' import https from 'https'
import http from 'http' import http from 'http'
import fs from 'fs'
import path from 'path'
import { URL } from 'url' import { URL } from 'url'
import { app, Notification } from 'electron' import { Notification } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { chatService, ChatSession, Message } from './chatService' import { chatService, ChatSession, Message } from './chatService'
@@ -38,6 +36,13 @@ const API_TIMEOUT_MS = 45_000
/** 沉默天数阈值默认值 */ /** 沉默天数阈值默认值 */
const DEFAULT_SILENCE_DAYS = 3 const DEFAULT_SILENCE_DAYS = 3
const INSIGHT_CONFIG_KEYS = new Set([
'aiInsightEnabled',
'aiInsightScanIntervalHours',
'dbPath',
'decryptKey',
'myWxid'
])
// ─── 类型 ──────────────────────────────────────────────────────────────────── // ─── 类型 ────────────────────────────────────────────────────────────────────
@@ -46,33 +51,17 @@ interface TodayTriggerRecord {
timestamps: number[] timestamps: number[]
} }
// ─── 桌面日志 ───────────────────────────────────────────────────────────────── // ─── 日志 ─────────────────────────────────────────────────────────────────────
/** /**
* 将日志同时输出到 console 和桌面上的 weflow-insight.log 文件。 * 输出到 console,不落盘到文件。
* 文件名带当天日期,每天自动换一个新文件,旧文件保留。
*/ */
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void { 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') { if (level === 'ERROR' || level === 'WARN') {
console.warn(`[InsightService] ${message}`) console.warn(`[InsightService] ${message}`)
} else { } else {
console.log(`[InsightService] ${message}`) 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 { start(): void {
if (this.started) return if (this.started) return
this.started = true this.started = true
insightLog('INFO', '已启动') void this.refreshConfiguration('startup')
this.scheduleSilenceScan()
} }
stop(): void { stop(): void {
const hadActiveFlow =
this.dbDebounceTimer !== null ||
this.silenceScanTimer !== null ||
this.silenceInitialDelayTimer !== null ||
this.processing
this.started = false 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.dbConnected = false
this.sessionCache = null this.sessionCache = null
this.sessionCacheAt = 0 this.sessionCacheAt = 0
this.lastActivityAnalysis.clear()
this.lastSeenTimestamp.clear()
this.todayTriggers.clear()
this.todayDate = getStartOfDay()
}
private clearTimers(): void {
if (this.dbDebounceTimer !== null) { if (this.dbDebounceTimer !== null) {
clearTimeout(this.dbDebounceTimer) clearTimeout(this.dbDebounceTimer)
this.dbDebounceTimer = null this.dbDebounceTimer = null
@@ -255,7 +293,6 @@ class InsightService {
clearTimeout(this.silenceInitialDelayTimer) clearTimeout(this.silenceInitialDelayTimer)
this.silenceInitialDelayTimer = null this.silenceInitialDelayTimer = null
} }
insightLog('INFO', '已停止')
} }
/** /**
@@ -452,9 +489,12 @@ class InsightService {
// ── 沉默联系人扫描 ────────────────────────────────────────────────────────── // ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
private scheduleSilenceScan(): void { private scheduleSilenceScan(): void {
this.clearTimers()
if (!this.started || !this.isEnabled()) return
// 等待扫描完成后再安排下一次,避免并发堆积 // 等待扫描完成后再安排下一次,避免并发堆积
const scheduleNext = () => { const scheduleNext = () => {
if (!this.started) return if (!this.started || !this.isEnabled()) return
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4 const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000 const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`) insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
@@ -474,7 +514,6 @@ class InsightService {
private async runSilenceScan(): Promise<void> { private async runSilenceScan(): Promise<void> {
if (!this.isEnabled()) { if (!this.isEnabled()) {
insightLog('INFO', '沉默扫描AI 见解未启用,跳过')
return return
} }
if (this.processing) { if (this.processing) {
@@ -502,6 +541,7 @@ class InsightService {
let silentCount = 0 let silentCount = 0
for (const session of sessions) { for (const session of sessions) {
if (!this.isEnabled()) return
const sessionId = session.username?.trim() || '' const sessionId = session.username?.trim() || ''
if (!sessionId || sessionId.endsWith('@chatroom')) continue if (!sessionId || sessionId.endsWith('@chatroom')) continue
if (sessionId.toLowerCase().includes('placeholder')) continue if (sessionId.toLowerCase().includes('placeholder')) continue
@@ -654,6 +694,7 @@ class InsightService {
}): Promise<void> { }): Promise<void> {
const { sessionId, displayName, triggerReason, silentDays } = params const { sessionId, displayName, triggerReason, silentDays } = params
if (!sessionId) return if (!sessionId) return
if (!this.isEnabled()) return
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string const apiKey = this.config.get('aiInsightApiKey') as string
@@ -747,6 +788,7 @@ class InsightService {
insightLog('INFO', `模型选择跳过 ${displayName}`) insightLog('INFO', `模型选择跳过 ${displayName}`)
return return
} }
if (!this.isEnabled()) return
const insight = result.slice(0, 120) const insight = result.slice(0, 120)
const notifTitle = `见解 · ${displayName}` const notifTitle = `见解 · ${displayName}`

View File

@@ -61,6 +61,7 @@ export class KeyService {
private getDllPath(): string { private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_DLL_PATH) { if (process.env.WX_KEY_DLL_PATH) {
@@ -68,11 +69,20 @@ export class KeyService {
} }
if (isPackaged) { 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, 'resources', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'wx_key.dll')) candidates.push(join(process.resourcesPath, 'wx_key.dll'))
} else { } else {
const cwd = process.cwd() 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(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')) candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
} }

View File

@@ -25,13 +25,23 @@ export class KeyServiceLinux {
private getHelperPath(): string { private getHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH) if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
if (isPackaged) { 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, 'resources', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'xkey_helper_linux')) candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
} else { } 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(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')) candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
} }
for (const p of candidates) { for (const p of candidates) {

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'
@@ -27,6 +27,7 @@ export class KeyServiceMac {
private getHelperPath(): string { private getHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) { if (process.env.WX_KEY_HELPER_PATH) {
@@ -34,12 +35,21 @@ export class KeyServiceMac {
} }
if (isPackaged) { 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, 'resources', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'xkey_helper')) candidates.push(join(process.resourcesPath, 'xkey_helper'))
} else { } else {
const cwd = process.cwd() 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, 'resources', 'xkey_helper'))
candidates.push(join(cwd, 'Xkey', 'build', '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')) candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
} }
@@ -52,14 +62,24 @@ export class KeyServiceMac {
private getImageScanHelperPath(): string { private getImageScanHelperPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (isPackaged) { 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, 'resources', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'image_scan_helper')) candidates.push(join(process.resourcesPath, 'image_scan_helper'))
} else { } else {
const cwd = process.cwd() 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(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')) candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
} }
@@ -72,6 +92,7 @@ export class KeyServiceMac {
private getDylibPath(): string { private getDylibPath(): string {
const isPackaged = app.isPackaged const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = [] const candidates: string[] = []
if (process.env.WX_KEY_DYLIB_PATH) { if (process.env.WX_KEY_DYLIB_PATH) {
@@ -79,11 +100,20 @@ export class KeyServiceMac {
} }
if (isPackaged) { 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, 'resources', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'libwx_key.dylib')) candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
} else { } else {
const cwd = process.cwd() 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(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')) candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
} }
@@ -373,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',
@@ -721,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 提权模式')
@@ -735,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) {
@@ -838,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

@@ -1,12 +1,5 @@
import dbus from "dbus-native"; import { Notification } from "electron";
import https from "https"; import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
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";
export interface LinuxNotificationData { export interface LinuxNotificationData {
sessionId?: string; sessionId?: string;
@@ -18,173 +11,96 @@ export interface LinuxNotificationData {
type NotificationCallback = (sessionId: string) => void; type NotificationCallback = (sessionId: string) => void;
let sessionBus: dbus.DBusConnection | null = null;
let notificationCallbacks: NotificationCallback[] = []; 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 function nextNotificationId(): number {
const avatarCache: Map<string, string> = new Map(); const id = notificationCounter;
// 缓存目录 notificationCounter += 1;
let avatarCacheDir: string | null = null; return id;
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 clearNotificationState(notificationId: number): void {
async function ensureCacheDir(): Promise<string> { activeNotifications.delete(notificationId);
if (!avatarCacheDir) { const timer = closeTimers.get(notificationId);
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars"); if (timer) {
clearTimeout(timer);
closeTimers.delete(notificationId);
}
}
function triggerNotificationCallback(sessionId: string): void {
for (const callback of notificationCallbacks) {
try { try {
await fs.mkdir(avatarCacheDir, { recursive: true }); callback(sessionId);
} catch (error) { } catch (error) {
console.error( console.error("[LinuxNotification] Callback error:", error);
"[LinuxNotification] Failed to create avatar cache dir:",
error,
);
} }
} }
return avatarCacheDir;
}
// 下载头像到本地临时文件
async function downloadAvatarToLocal(url: string): Promise<string | null> {
// 检查缓存
if (avatarCache.has(url)) {
return avatarCache.get(url) || null;
}
try {
const cacheDir = await ensureCacheDir();
// 生成唯一文件名
const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`;
const localPath = join(cacheDir, fileName);
await new Promise<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;
} catch (error) {
console.error("[LinuxNotification] Failed to download avatar:", error);
return null;
}
} }
export async function showLinuxNotification( export async function showLinuxNotification(
data: LinuxNotificationData, data: LinuxNotificationData,
): Promise<number | null> { ): Promise<number | null> {
if (process.platform !== "linux") {
return null;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
return null;
}
try { try {
const bus = await getSessionBus(); let iconPath: string | undefined;
const appName = "WeFlow";
const replaceId = 0;
const expireTimeout = data.expireTimeout ?? 5000;
// 处理头像下载到本地或使用URL
let appIcon = "";
let hints: any[] = [];
if (data.avatarUrl) { if (data.avatarUrl) {
// 优先尝试下载到本地 iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
const localPath = await downloadAvatarToLocal(data.avatarUrl);
if (localPath) {
hints = [["image-path", ["s", localPath]]];
}
} }
return new Promise((resolve, reject) => { const notification = new Notification({
bus.invoke( title: data.title,
{ body: data.content,
destination: BUS_NAME, icon: iconPath,
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 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) { } catch (error) {
console.error("[LinuxNotification] Failed to show notification:", error); console.error("[LinuxNotification] Failed to show notification:", error);
return null; return null;
@@ -194,59 +110,22 @@ export async function showLinuxNotification(
export async function closeLinuxNotification( export async function closeLinuxNotification(
notificationId: number, notificationId: number,
): Promise<void> { ): Promise<void> {
try { const notification = activeNotifications.get(notificationId);
const bus = await getSessionBus(); if (!notification) return;
return new Promise((resolve, reject) => { notification.close();
bus.invoke( clearNotificationState(notificationId);
{
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);
}
} }
export async function getCapabilities(): Promise<string[]> { export async function getCapabilities(): Promise<string[]> {
try { if (process.platform !== "linux") {
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);
return []; return [];
} }
if (!Notification.isSupported()) {
return [];
}
return ["native-notification", "click"];
} }
export function onNotificationAction(callback: NotificationCallback): void { 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> { export async function initLinuxNotificationService(): Promise<void> {
if (process.platform !== "linux") { if (process.platform !== "linux") {
console.log("[LinuxNotification] Not on Linux, skipping init"); console.log("[LinuxNotification] Not on Linux, skipping init");
return; return;
} }
try { if (!Notification.isSupported()) {
const bus = await getSessionBus(); console.warn("[LinuxNotification] Notification API is not supported");
return;
// 监听底层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);
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);
} }
const caps = await getCapabilities();
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 videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly hardlinkCacheMaxEntries = 20000 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 logTimer: NodeJS.Timeout | null = null
private lastLogTail: string | null = null private lastLogTail: string | null = null
private lastResolvedLogPath: string | null = null private lastResolvedLogPath: string | null = null
@@ -277,7 +280,9 @@ export class WcdbCore {
const isLinux = process.platform === 'linux' const isLinux = process.platform === 'linux'
const isArm64 = process.arch === 'arm64' const isArm64 = process.arch === 'arm64'
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' 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 const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) { if (envDllPath && envDllPath.length > 0) {
@@ -287,20 +292,33 @@ export class WcdbCore {
// 基础路径探测 // 基础路径探测
const isPackaged = typeof process['resourcesPath'] !== 'undefined' const isPackaged = typeof process['resourcesPath'] !== 'undefined'
const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources') const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources')
const roots = [
const candidates = [ process.env.WCDB_RESOURCES_PATH || null,
// 环境变量指定 resource 目录 this.resourcesPath || null,
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null, join(resourcesPath, 'resources'),
// 显式 setPaths 设置的路径 resourcesPath,
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null, join(process.cwd(), 'resources')
// 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)
].filter(Boolean) as string[] ].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) { for (const path of candidates) {
if (existsSync(path)) return path if (existsSync(path)) return path
} }
@@ -1465,6 +1483,11 @@ export class WcdbCore {
this.videoHardlinkCache.clear() this.videoHardlinkCache.clear()
} }
private clearMediaStreamSessionCache(): void {
this.mediaStreamSessionCache = null
this.mediaStreamSessionCacheAt = 0
}
isReady(): boolean { isReady(): boolean {
return this.ensureReady() return this.ensureReady()
} }
@@ -1580,6 +1603,7 @@ export class WcdbCore {
this.currentDbStoragePath = null this.currentDbStoragePath = null
this.initialized = false this.initialized = false
this.clearHardlinkCaches() this.clearHardlinkCaches()
this.clearMediaStreamSessionCache()
this.stopLogPolling() this.stopLogPolling()
} }
} }
@@ -1957,7 +1981,7 @@ export class WcdbCore {
error?: string error?: string
}> { }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } 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 { try {
const toInt = (value: unknown): number => { const toInt = (value: unknown): number => {
const n = Number(value || 0) const n = Number(value || 0)
@@ -2168,37 +2192,64 @@ export class WcdbCore {
const offset = Math.max(0, toInt(options?.offset)) const offset = Math.max(0, toInt(options?.offset))
const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240)) const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240))
const sessionsRes = await this.getSessions() const getSessionRows = async (): Promise<{
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) { success: boolean
return { success: false, error: sessionsRes.error || '读取会话失败' } rows?: Array<{ sessionId: string; displayName: string; sortTimestamp: number }>
error?: string
}> => {
const now = Date.now()
const cachedRows = this.mediaStreamSessionCache
if (
cachedRows &&
now - this.mediaStreamSessionCacheAt <= this.mediaStreamSessionCacheTtlMs
) {
return { success: true, rows: cachedRows }
}
const sessionsRes = await this.getSessions()
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
return { success: false, error: sessionsRes.error || '读取会话失败' }
}
const rows = (sessionsRes.sessions || [])
.map((row: any) => ({
sessionId: String(
row.username ||
row.user_name ||
row.userName ||
row.usrName ||
row.UsrName ||
row.talker ||
''
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.last_timestamp ||
row.lastTimestamp ||
0
)
}))
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
this.mediaStreamSessionCache = rows
this.mediaStreamSessionCacheAt = now
return { success: true, rows }
} }
const sessions = (sessionsRes.sessions || []) let sessionRows: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> = []
.map((row: any) => ({ if (requestedSessionId) {
sessionId: String( sessionRows = [{ sessionId: requestedSessionId, displayName: requestedSessionId, sortTimestamp: 0 }]
row.username || } else {
row.user_name || const sessionsRowsRes = await getSessionRows()
row.userName || if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) {
row.usrName || return { success: false, error: sessionsRowsRes.error || '读取会话失败' }
row.UsrName || }
row.talker || sessionRows = sessionsRowsRes.rows
'' }
).trim(),
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
sortTimestamp: toInt(
row.sort_timestamp ||
row.sortTimestamp ||
row.last_timestamp ||
row.lastTimestamp ||
0
)
}))
.filter((row) => Boolean(row.sessionId))
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
const sessionRows = requestedSessionId
? sessions.filter((row) => row.sessionId === requestedSessionId)
: sessions
if (sessionRows.length === 0) { if (sessionRows.length === 0) {
return { success: true, items: [], hasMore: false, nextOffset: offset } return { success: true, items: [], hasMore: false, nextOffset: offset }
} }
@@ -2219,10 +2270,10 @@ export class WcdbCore {
outHasMore outHasMore
) )
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
return { success: false, error: `扫描媒体流失败: ${result}` } return { success: false, error: `扫描资源失败: ${result}` }
} }
const jsonStr = this.decodeJsonPtr(outPtr[0]) const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析媒体流失败' } if (!jsonStr) return { success: false, error: '解析资源失败' }
const rows = JSON.parse(jsonStr) const rows = JSON.parse(jsonStr)
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : [] const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
@@ -2254,19 +2305,39 @@ export class WcdbCore {
rawMessageContent && rawMessageContent &&
(rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg')) (rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg'))
) )
const content = useRawMessageContent const decodeContentIfNeeded = (): string => {
? rawMessageContent if (useRawMessageContent) return rawMessageContent
: decodeMessageContent(rawMessageContent, rawCompressContent) if (!rawMessageContent && !rawCompressContent) return ''
return decodeMessageContent(rawMessageContent, rawCompressContent)
}
const packedPayload = extractPackedPayload(row) const packedPayload = extractPackedPayload(row)
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5']) 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 videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
const videoMd5 = localType === 43
? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined) let content = ''
: undefined 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 { return {
sessionId, sessionId,
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId, sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
@@ -2280,7 +2351,7 @@ export class WcdbCore {
imageMd5, imageMd5,
imageDatName, imageDatName,
videoMd5, 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; 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()) { if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null; notificationWindow = null;
return; return;

211
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@vscode/sudo-prompt": "^9.3.2", "@vscode/sudo-prompt": "^9.3.2",
"dbus-native": "^0.4.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
@@ -45,7 +44,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-electron": "^0.28.8", "vite-plugin-electron": "^0.29.1",
"vite-plugin-electron-renderer": "^0.14.6" "vite-plugin-electron-renderer": "^0.14.6"
} }
}, },
@@ -3084,25 +3083,6 @@
"node": "^18.17.0 || >=20.5.0" "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": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -3615,16 +3595,6 @@
"node": "*" "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": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -4459,27 +4429,6 @@
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
"license": "MIT" "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": { "node_modules/debounce-fn": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
@@ -4848,12 +4797,6 @@
"node": ">= 0.4" "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": { "node_modules/duplexer2": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@@ -5379,21 +5322,6 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/exceljs": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
@@ -5570,13 +5498,6 @@
"node": ">= 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": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -5664,12 +5585,6 @@
"node": ">= 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": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -6069,15 +5984,6 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/hosted-git-info": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "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" "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": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "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": "^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": { "node_modules/markdown-table": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -8023,13 +7917,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -8222,22 +8109,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/ora": {
"version": "5.4.1", "version": "5.4.1",
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
@@ -8387,18 +8258,6 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/pe-library": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
@@ -8597,15 +8456,6 @@
"node": ">=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": { "node_modules/quick-lru": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@@ -9467,18 +9317,6 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/sprintf-js": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@@ -9510,16 +9348,6 @@
"node": ">= 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": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -9788,12 +9616,6 @@
"utrie": "^1.0.2" "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": { "node_modules/tiny-async-pool": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@@ -10380,15 +10202,6 @@
"node": ">= 8" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -10432,28 +10245,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "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": { "node_modules/xmlbuilder": {
"version": "15.1.1", "version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",

View File

@@ -9,7 +9,7 @@
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/Jasonzhu1207/WeFlow" "url": "https://github.com/hicccc77/WeFlow"
}, },
"//": "二改不应改变此处的作者与应用信息", "//": "二改不应改变此处的作者与应用信息",
"scripts": { "scripts": {
@@ -24,7 +24,6 @@
}, },
"dependencies": { "dependencies": {
"@vscode/sudo-prompt": "^9.3.2", "@vscode/sudo-prompt": "^9.3.2",
"dbus-native": "^0.4.0",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2", "electron-store": "^11.0.2",
@@ -59,7 +58,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^7.3.2", "vite": "^7.3.2",
"vite-plugin-electron": "^0.28.8", "vite-plugin-electron": "^0.29.1",
"vite-plugin-electron-renderer": "^0.14.6" "vite-plugin-electron-renderer": "^0.14.6"
}, },
"pnpm": { "pnpm": {
@@ -71,14 +70,16 @@
"lodash": ">=4.17.21", "lodash": ">=4.17.21",
"brace-expansion": ">=1.1.11", "brace-expansion": ">=1.1.11",
"picomatch": ">=2.3.1", "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": { "build": {
"appId": "com.WeFlow.app", "appId": "com.WeFlow.app",
"publish": { "publish": {
"provider": "github", "provider": "github",
"owner": "Jasonzhu1207", "owner": "hicccc77",
"repo": "WeFlow", "repo": "WeFlow",
"releaseType": "release" "releaseType": "release"
}, },
@@ -97,7 +98,7 @@
"gatekeeperAssess": false, "gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist", "entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist", "entitlementsInherit": "electron/entitlements.mac.plist",
"icon": "resources/icon.icns" "icon": "resources/icons/macos/icon.icns"
}, },
"win": { "win": {
"target": [ "target": [
@@ -106,19 +107,19 @@
"icon": "public/icon.ico", "icon": "public/icon.ico",
"extraFiles": [ "extraFiles": [
{ {
"from": "resources/msvcp140.dll", "from": "resources/runtime/win32/msvcp140.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/msvcp140_1.dll", "from": "resources/runtime/win32/msvcp140_1.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/vcruntime140.dll", "from": "resources/runtime/win32/vcruntime140.dll",
"to": "." "to": "."
}, },
{ {
"from": "resources/vcruntime140_1.dll", "from": "resources/runtime/win32/vcruntime140_1.dll",
"to": "." "to": "."
} }
] ]
@@ -134,7 +135,7 @@
"synopsis": "WeFlow for Linux", "synopsis": "WeFlow for Linux",
"extraFiles": [ "extraFiles": [
{ {
"from": "resources/linux/install.sh", "from": "resources/installer/linux/install.sh",
"to": "install.sh" "to": "install.sh"
} }
] ]
@@ -189,7 +190,7 @@
"node_modules/sherpa-onnx-*/**/*", "node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*" "node_modules/ffmpeg-static/**/*"
], ],
"icon": "resources/icon.icns" "icon": "resources/icons/macos/icon.icns"
}, },
"overrides": { "overrides": {
"picomatch": "^4.0.4", "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 [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null) 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(() => { useEffect(() => {
if (location.pathname !== '/settings') { if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location settingsBackgroundRef.current = location
@@ -339,6 +301,21 @@ function App() {
} }
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow]) }, [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(() => { useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) { if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
@@ -670,33 +647,6 @@ function App() {
</div> </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 <UpdateDialog
open={showUpdateDialog} open={showUpdateDialog}

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

@@ -1965,6 +1965,10 @@
color: var(--on-primary); color: var(--on-primary);
border-radius: 18px 18px 4px 18px; border-radius: 18px 18px 4px 18px;
} }
.bubble-body {
align-items: flex-end;
}
} }
// 对方发送的消息 - 左侧白色 // 对方发送的消息 - 左侧白色
@@ -1974,6 +1978,10 @@
color: var(--text-primary); color: var(--text-primary);
border-radius: 18px 18px 18px 4px; border-radius: 18px 18px 18px 4px;
} }
.bubble-body {
align-items: flex-start;
}
} }
&.system { &.system {
@@ -2038,6 +2046,12 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
// 让文字气泡按内容收缩,不被群昵称行宽度牵连
.message-bubble:not(.system) .bubble-content {
width: fit-content;
max-width: 100%;
}
// 表情包消息 // 表情包消息
.message-bubble.emoji { .message-bubble.emoji {
.bubble-content { .bubble-content {

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' 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 { 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 { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
@@ -1142,6 +1142,7 @@ function ChatPage(props: ChatPageProps) {
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType]) const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { const {
isConnected, isConnected,
@@ -5350,6 +5351,19 @@ function ChatPage(props: ChatPageProps) {
selectSessionById 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(() => { useEffect(() => {
if (!standaloneSessionWindow || !normalizedInitialSessionId) return if (!standaloneSessionWindow || !normalizedInitialSessionId) return
if (!isConnected || isConnecting) { if (!isConnected || isConnecting) {

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

@@ -1,6 +1,7 @@
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' 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 { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
import { VirtuosoGrid } from 'react-virtuoso' import { VirtuosoGrid } from 'react-virtuoso'
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
import './ResourcesPage.scss' import './ResourcesPage.scss'
type MediaTab = 'image' | 'video' type MediaTab = 'image' | 'video'
@@ -35,10 +36,14 @@ type DialogState = {
onConfirm?: (() => void) | null onConfirm?: (() => void) | null
} }
const PAGE_SIZE = 120 const PAGE_SIZE = 96
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18 const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36 const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4 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 GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
const { className = '', ...rest } = props const { className = '', ...rest } = props
@@ -409,7 +414,13 @@ function ResourcesPage() {
} }
try { 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 requestOffset = reset ? 0 : nextOffset
const streamResult = await window.electronAPI.chat.getMediaStream({ const streamResult = await window.electronAPI.chat.getMediaStream({
sessionId: selectedContact === 'all' ? undefined : selectedContact, sessionId: selectedContact === 'all' ? undefined : selectedContact,
@@ -524,7 +535,6 @@ function ResourcesPage() {
let cancelled = false let cancelled = false
const run = async () => { const run = async () => {
try { try {
await window.electronAPI.chat.connect()
const sessionResult = await window.electronAPI.chat.getSessions() const sessionResult = await window.electronAPI.chat.getSessions()
if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) { if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) {
const initialNameMap: Record<string, string> = {} const initialNameMap: Record<string, string> = {}
@@ -674,7 +684,10 @@ function ResourcesPage() {
resolvingImageCacheBatchRef.current = true resolvingImageCacheBatchRef.current = true
void (async () => { void (async () => {
try { 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 rows = Array.isArray(result?.rows) ? result.rows : []
const pathPatch: Record<string, string> = {} const pathPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {} const updatePatch: Record<string, boolean> = {}
@@ -741,7 +754,10 @@ function ResourcesPage() {
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
} }
if (payloads.length === 0) return if (payloads.length === 0) return
void window.electronAPI.image.preload(payloads, { allowDecrypt: false }) void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: false
})
}, [displayItems]) }, [displayItems])
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => { const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
@@ -813,14 +829,18 @@ function ResourcesPage() {
if (!pending) return if (!pending) return
pendingRangeRef.current = null pendingRangeRef.current = null
if (tab === 'image') { if (tab === 'image') {
preloadImageCacheRange(pending.start - 8, pending.end + 32) preloadImageCacheRange(pending.start - 4, pending.end + 20)
resolveImageCacheRange(pending.start - 2, pending.end + 8) resolveImageCacheRange(pending.start - 1, pending.end + 6)
return return
} }
resolvePosterRange(pending.start, pending.end) resolvePosterRange(pending.start, pending.end)
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab]) }, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
const scheduleRangeResolve = useCallback((start: number, end: number) => { const scheduleRangeResolve = useCallback((start: number, end: number) => {
const previous = pendingRangeRef.current
if (previous && start >= previous.start && end <= previous.end) {
return
}
pendingRangeRef.current = { start, end } pendingRangeRef.current = { start, end }
if (rangeTimerRef.current !== null) { if (rangeTimerRef.current !== null) {
window.clearTimeout(rangeTimerRef.current) window.clearTimeout(rangeTimerRef.current)
@@ -832,8 +852,8 @@ function ResourcesPage() {
useEffect(() => { useEffect(() => {
if (displayItems.length === 0) return if (displayItems.length === 0) return
if (tab === 'image') { if (tab === 'image') {
preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80)) preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20)) resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
return return
} }
resolvePosterRange(0, Math.min(displayItems.length - 1, 12)) resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
@@ -1057,25 +1077,61 @@ function ResourcesPage() {
setBatchBusy(true) setBatchBusy(true)
let success = 0 let success = 0
let failed = 0
const previewPatch: Record<string, string> = {} const previewPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {} const updatePatch: Record<string, boolean> = {}
const taskId = registerBackgroundTask({
sourcePage: 'other',
title: '资源页图片批量解密',
detail: `正在解密图片0/${imageItems.length}`,
progressText: `0 / ${imageItems.length}`,
cancelable: false
})
try { 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) { 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({ const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId, sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined, imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined, imageDatName: item.imageDatName || undefined,
force: true force: true
}) })
if (!result?.success) continue if (!result?.success) {
success += 1 failed += 1
if (result.localPath) { } else {
const key = getItemKey(item) success += 1
previewPatch[key] = result.localPath if (result.localPath) {
updatePatch[key] = isLikelyThumbnailPreview(result.localPath) const key = getItemKey(item)
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
}
} }
completed += 1
updateTaskProgress()
} }
updateTaskProgress(true)
if (Object.keys(previewPatch).length > 0) { if (Object.keys(previewPatch).length > 0) {
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch })) setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
@@ -1083,8 +1139,17 @@ function ResourcesPage() {
if (Object.keys(updatePatch).length > 0) { if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch })) setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
} }
setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`) setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成') 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 { } finally {
setBatchBusy(false) setBatchBusy(false)
} }

View File

@@ -238,23 +238,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('') const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('')
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = 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 可用性 // 检查 Hello 可用性
useEffect(() => { useEffect(() => {
setHelloAvailable(isWindows) setHelloAvailable(isWindows)
@@ -1474,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 => {
@@ -1530,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>
@@ -1672,7 +1652,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"><EFBFBD><EFBFBD><EFBFBD></span> <span className="form-hint"></span>
<div className="log-toggle-line"> <div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span> <span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle"> <label className="switch" htmlFor="notification-enabled-toggle">
@@ -1696,11 +1676,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <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">
<div <div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`} className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
@@ -3676,7 +3651,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<div className="updates-hero-main"> <div className="updates-hero-main">
<span className="updates-chip"></span> <span className="updates-chip"></span>
<h2>{appVersion || '...'}</h2> <h2>{appVersion || '...'}</h2>
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更<EFBFBD><EFBFBD><EFBFBD>'}</p> <p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更'}</p>
</div> </div>
<div className="updates-hero-action"> <div className="updates-hero-action">
{updateInfo?.hasUpdate ? ( {updateInfo?.hasUpdate ? (

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)

View File

@@ -1,4 +1,10 @@
import { create } from 'zustand' import { create } from 'zustand'
import {
finishBackgroundTask,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
export interface BatchImageDecryptState { export interface BatchImageDecryptState {
isBatchDecrypting: boolean isBatchDecrypting: boolean
@@ -8,8 +14,9 @@ export interface BatchImageDecryptState {
result: { success: number; fail: number } result: { success: number; fail: number }
startTime: number startTime: number
sessionName: string 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 updateProgress: (current: number, total: number) => void
finishDecrypt: (success: number, fail: number) => void finishDecrypt: (success: number, fail: number) => void
setShowToast: (show: boolean) => void setShowToast: (show: boolean) => void
@@ -17,7 +24,26 @@ export interface BatchImageDecryptState {
reset: () => void 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, isBatchDecrypting: false,
progress: { current: 0, total: 0 }, progress: { current: 0, total: 0 },
showToast: false, showToast: false,
@@ -25,40 +51,127 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) =>
result: { success: 0, fail: 0 }, result: { success: 0, fail: 0 },
startTime: 0, startTime: 0,
sessionName: '', sessionName: '',
taskId: null,
startDecrypt: (total, sessionName) => set({ startDecrypt: (total, sessionName, sourcePage = 'chat') => {
isBatchDecrypting: true, const previousTaskId = get().taskId
progress: { current: 0, total }, if (previousTaskId) {
showToast: true, taskProgressUpdateMeta.delete(previousTaskId)
showResultToast: false, finishBackgroundTask(previousTaskId, 'canceled', {
result: { success: 0, fail: 0 }, detail: '已被新的批量解密任务替换',
startTime: Date.now(), progressText: '已替换'
sessionName })
}), }
updateProgress: (current, total) => set({ const normalizedProgress = clampProgress(0, total)
progress: { current, 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)
})
finishDecrypt: (success, fail) => set({ set({
isBatchDecrypting: false, isBatchDecrypting: true,
showToast: false, progress: normalizedProgress,
showResultToast: true, showToast: true,
result: { success, fail }, showResultToast: false,
startTime: 0 result: { success: 0, fail: 0 },
}), startTime: Date.now(),
sessionName: normalizedSessionName,
taskId
})
},
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) => {
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: normalizedSuccess, fail: normalizedFail },
startTime: 0,
taskId: null
})
},
setShowToast: (show) => set({ showToast: show }), setShowToast: (show) => set({ showToast: show }),
setShowResultToast: (show) => set({ showResultToast: show }), setShowResultToast: (show) => set({ showResultToast: show }),
reset: () => set({ reset: () => {
isBatchDecrypting: false, const taskId = get().taskId
progress: { current: 0, total: 0 }, if (taskId) {
showToast: false, taskProgressUpdateMeta.delete(taskId)
showResultToast: false, finishBackgroundTask(taskId, 'canceled', {
result: { success: 0, fail: 0 }, detail: '批量解密任务已重置',
startTime: 0, progressText: '已停止'
sessionName: '' })
}) }
}))
set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
taskId: null
})
}
}))

View File

@@ -69,7 +69,6 @@ export interface ElectronAPI {
ignoreUpdate: (version: string) => Promise<{ success: boolean }> ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
checkWayland: () => Promise<boolean>
} }
notification: { notification: {
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void> 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 ready: () => void
resize: (width: number, height: number) => void resize: (width: number, height: number) => void
onShow: (callback: (event: any, data: any) => void) => () => void onShow: (callback: (event: any, data: any) => void) => () => void
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
} }
log: { log: {
getPath: () => Promise<string> getPath: () => Promise<string>
@@ -403,10 +403,16 @@ export interface ElectronAPI {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> 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: ( resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean } options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
) => Promise<{ ) => Promise<{
success: boolean success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
@@ -414,7 +420,7 @@ export interface ElectronAPI {
}> }>
preload: ( preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>, payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { allowDecrypt?: boolean } options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean> ) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: 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 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'