chore: merge upstream main into fork main
This commit is contained in:
commit
36bed846b2
62 files changed
+1693
-901
No files matched your search
@@ -6,6 +6,10 @@ on:
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: dev-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -56,7 +60,23 @@ jobs:
|
||||
fi
|
||||
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
||||
RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after create:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dev-mac-arm64:
|
||||
needs: prepare
|
||||
@@ -77,6 +97,22 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
@@ -266,21 +302,25 @@ jobs:
|
||||
- name: Update fixed dev release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$FIXED_DEV_TAG"
|
||||
TAG="${FIXED_DEV_TAG:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "FIXED_DEV_TAG is empty, abort."
|
||||
exit 1
|
||||
fi
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
echo "Using release tag: $TAG"
|
||||
|
||||
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG not found, skip notes update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
@@ -329,9 +369,39 @@ jobs:
|
||||
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
|
||||
EOF
|
||||
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
jq -n --rawfile body dev_release_notes.md \
|
||||
'{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \
|
||||
> release_update_payload.json
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after notes update:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
@@ -6,6 +6,10 @@ on:
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: preview-nightly-fixed-release
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -82,7 +86,23 @@ jobs:
|
||||
fi
|
||||
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
||||
RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after create:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
preview-mac-arm64:
|
||||
needs: prepare
|
||||
@@ -104,6 +124,22 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
@@ -311,17 +347,22 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$FIXED_PREVIEW_TAG"
|
||||
TAG="${FIXED_PREVIEW_TAG:-}"
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "FIXED_PREVIEW_TAG is empty, abort."
|
||||
exit 1
|
||||
fi
|
||||
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
echo "Using release tag: $TAG"
|
||||
|
||||
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||
|
||||
pick_asset() {
|
||||
local pattern="$1"
|
||||
@@ -371,9 +412,39 @@ jobs:
|
||||
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
|
||||
EOF
|
||||
|
||||
update_release_notes() {
|
||||
local attempts=5
|
||||
local delay_seconds=2
|
||||
local i
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
jq -n --rawfile body preview_release_notes.md \
|
||||
'{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \
|
||||
> release_update_payload.json
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after notes update:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
@@ -31,12 +31,28 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in \
|
||||
resources/key/macos/universal/xkey_helper \
|
||||
resources/key/macos/universal/image_scan_helper \
|
||||
resources/key/macos/universal/xkey_helper_macos \
|
||||
resources/key/macos/universal/libwx_key.dylib
|
||||
do
|
||||
if [ -f "$file" ]; then
|
||||
chmod +x "$file"
|
||||
ls -l "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
@@ -93,7 +109,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
@@ -115,7 +131,7 @@ jobs:
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null || true
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||
@@ -144,7 +160,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
@@ -166,7 +182,7 @@ jobs:
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null || true
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||
@@ -195,7 +211,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
@@ -217,7 +233,7 @@ jobs:
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null || true
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Security Scan
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||
@@ -24,15 +27,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.branch }}
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '24'
|
||||
cache: 'npm' # 使用 npm 缓存加速
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -71,10 +74,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run npm audit on all branches
|
||||
run: |
|
||||
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||
@@ -84,4 +93,4 @@ jobs:
|
||||
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
|
||||
npm audit --audit-level=moderate 2>/dev/null || true
|
||||
done
|
||||
continue-on-error: true
|
||||
continue-on-error: true
|
||||
+3
-1
@@ -56,6 +56,8 @@ Thumbs.db
|
||||
*.aps
|
||||
|
||||
wcdb/
|
||||
!resources/wcdb/
|
||||
!resources/wcdb/**
|
||||
xkey/
|
||||
server/
|
||||
*info
|
||||
@@ -73,4 +75,4 @@ pnpm-lock.yaml
|
||||
wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
Insight
|
||||
Insight
|
||||
@@ -1,34 +1,23 @@
|
||||
# WeFlow
|
||||
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
|
||||
|
||||
---
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||
|
||||
<p align="center">
|
||||
<img src="app.png" alt="WeFlow" width="90%">
|
||||
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/hicccc77/WeFlow/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square" alt="Stargazers">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/network/members">
|
||||
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/releases">
|
||||
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://t.me/weflow_cc">
|
||||
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||
</a>
|
||||
<!-- 第一行修复样式 -->
|
||||
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
|
||||
<br><br>
|
||||
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
|
||||
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
|
||||
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
|
||||
</p>
|
||||
|
||||
|
||||
> [!TIP]
|
||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||
|
||||
@@ -47,14 +36,12 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
|
||||
## 支持平台与设备
|
||||
|
||||
|
||||
| 平台 | 设备/架构 | 安装包 |
|
||||
|------|----------|--------|
|
||||
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||
@@ -93,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
||||
|
||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
|
||||
## 面向开发者
|
||||
|
||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||
@@ -108,9 +94,24 @@ npm install
|
||||
|
||||
# 3. 运行应用(开发模式)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
## 构建状态
|
||||
|
||||
用于开发者排查发布链路,普通用户可忽略:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/release.yml">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/release.yml?branch=main&label=Release&style=flat&labelColor=111827&color=22C55E" alt="Release Workflow">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/preview-nightly-main.yml">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/preview-nightly-main.yml?branch=main&label=Preview%20Nightly&style=flat&labelColor=111827&color=F59E0B" alt="Preview Nightly Workflow">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/dev-daily-fixed.yml">
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/dev-daily-fixed.yml?branch=dev&label=Dev%20Daily&style=flat&labelColor=111827&color=A78BFA" alt="Dev Daily Workflow">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## 致谢
|
||||
|
||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
@@ -120,18 +121,16 @@ npm run dev
|
||||
|
||||
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
||||
<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: 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" />
|
||||
</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: 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" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
|
||||
|
||||
function stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
|
||||
const lower = fileName.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
|
||||
+89
-20
@@ -182,7 +182,6 @@ const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
|
||||
autoUpdater.channel = nextUpdaterChannel
|
||||
lastAppliedUpdaterChannel = nextUpdaterChannel
|
||||
lastAppliedUpdaterFeedUrl = nextFeedUrl
|
||||
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},feed=${nextFeedUrl},allowDowngrade=${autoUpdater.allowDowngrade}`)
|
||||
}
|
||||
|
||||
applyAutoUpdateChannel('startup')
|
||||
@@ -1619,6 +1618,7 @@ function registerIpcHandlers() {
|
||||
applyAutoUpdateChannel('settings')
|
||||
}
|
||||
void messagePushService.handleConfigChanged(key)
|
||||
void insightService.handleConfigChanged(key)
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -1644,6 +1644,7 @@ function registerIpcHandlers() {
|
||||
}
|
||||
configService?.clear()
|
||||
messagePushService.handleConfigCleared()
|
||||
insightService.handleConfigCleared()
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -1692,13 +1693,6 @@ function registerIpcHandlers() {
|
||||
return applyLaunchAtStartupPreference(enabled === true)
|
||||
})
|
||||
|
||||
ipcMain.handle('app:checkWayland', async () => {
|
||||
if (process.platform !== 'linux') return false;
|
||||
|
||||
const sessionType = process.env.XDG_SESSION_TYPE?.toLowerCase();
|
||||
return Boolean(process.env.WAYLAND_DISPLAY || sessionType === 'wayland');
|
||||
})
|
||||
|
||||
ipcMain.handle('log:getPath', async () => {
|
||||
return join(app.getPath('userData'), 'logs', 'wcdb.log')
|
||||
})
|
||||
@@ -2572,7 +2566,13 @@ function registerIpcHandlers() {
|
||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||
return imageDecryptService.decryptImage(payload)
|
||||
})
|
||||
ipcMain.handle('image:resolveCache', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => {
|
||||
ipcMain.handle('image:resolveCache', async (_, payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) => {
|
||||
return imageDecryptService.resolveCachedImage(payload)
|
||||
})
|
||||
ipcMain.handle(
|
||||
@@ -2580,13 +2580,14 @@ function registerIpcHandlers() {
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean }
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
) => {
|
||||
const list = Array.isArray(payloads) ? payloads : []
|
||||
const rows = await Promise.all(list.map(async (payload) => {
|
||||
return imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false
|
||||
})
|
||||
}))
|
||||
return { success: true, rows }
|
||||
@@ -2597,7 +2598,7 @@ function registerIpcHandlers() {
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { allowDecrypt?: boolean }
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => {
|
||||
imagePreloadService.enqueue(payloads || [], options)
|
||||
return true
|
||||
@@ -3454,12 +3455,38 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
const withTimeout = <T>(task: () => Promise<T>, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve({ timedOut: true, error: `timeout(${timeoutMs}ms)` })
|
||||
}, timeoutMs)
|
||||
|
||||
task()
|
||||
.then((value) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ timedOut: false, value })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ timedOut: false, error: String(error) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化配置服务
|
||||
updateSplashProgress(5, '正在加载配置...')
|
||||
configService = new ConfigService()
|
||||
applyAutoUpdateChannel('startup')
|
||||
syncLaunchAtStartupPreference()
|
||||
const onboardingDone = configService.get('onboardingDone') === true
|
||||
shouldShowMain = onboardingDone
|
||||
|
||||
// 将用户主题配置推送给 Splash 窗口
|
||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||
@@ -3472,7 +3499,7 @@ app.whenReady().then(async () => {
|
||||
await delay(200)
|
||||
|
||||
// 设置资源路径
|
||||
updateSplashProgress(10, '正在初始化...')
|
||||
updateSplashProgress(12, '正在初始化...')
|
||||
const candidateResources = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
@@ -3482,13 +3509,13 @@ app.whenReady().then(async () => {
|
||||
await delay(200)
|
||||
|
||||
// 初始化数据库服务
|
||||
updateSplashProgress(18, '正在初始化...')
|
||||
updateSplashProgress(20, '正在初始化...')
|
||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||
await delay(200)
|
||||
|
||||
// 注册 IPC 处理器
|
||||
updateSplashProgress(25, '正在初始化...')
|
||||
updateSplashProgress(28, '正在初始化...')
|
||||
registerIpcHandlers()
|
||||
chatService.addDbMonitorListener((type, json) => {
|
||||
messagePushService.handleDbMonitorChange(type, json)
|
||||
@@ -3498,12 +3525,54 @@ app.whenReady().then(async () => {
|
||||
insightService.start()
|
||||
await delay(200)
|
||||
|
||||
// 检查配置状态
|
||||
const onboardingDone = configService.get('onboardingDone')
|
||||
shouldShowMain = onboardingDone === true
|
||||
// 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
|
||||
if (onboardingDone) {
|
||||
updateSplashProgress(34, '正在连接数据库...')
|
||||
const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
|
||||
const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
|
||||
|
||||
if (!connected) {
|
||||
const reason = connectWarmup.timedOut
|
||||
? connectWarmup.error
|
||||
: (connectWarmup.value?.error || connectWarmup.error || 'unknown')
|
||||
console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
|
||||
updateSplashProgress(68, '数据库预热已跳过')
|
||||
} else {
|
||||
const preloadUsernames = new Set<string>()
|
||||
|
||||
updateSplashProgress(44, '正在预加载会话...')
|
||||
const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
|
||||
if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
|
||||
for (const session of sessionsWarmup.value.sessions) {
|
||||
const username = String((session as any)?.username || '').trim()
|
||||
if (username) preloadUsernames.add(username)
|
||||
}
|
||||
}
|
||||
|
||||
updateSplashProgress(56, '正在预加载联系人...')
|
||||
const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000)
|
||||
if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) {
|
||||
for (const contact of contactsWarmup.value.contacts) {
|
||||
const username = String((contact as any)?.username || '').trim()
|
||||
if (username) preloadUsernames.add(username)
|
||||
}
|
||||
}
|
||||
|
||||
updateSplashProgress(63, '正在缓存联系人头像...')
|
||||
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
|
||||
if (avatarWarmupUsernames.length > 0) {
|
||||
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
|
||||
}
|
||||
|
||||
updateSplashProgress(68, '正在初始化消息库索引...')
|
||||
await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000)
|
||||
}
|
||||
} else {
|
||||
updateSplashProgress(68, '首次启动准备中...')
|
||||
}
|
||||
|
||||
// 创建主窗口(不显示,由启动流程统一控制)
|
||||
updateSplashProgress(30, '正在加载界面...')
|
||||
updateSplashProgress(70, '正在准备主窗口...')
|
||||
mainWindow = createWindow({ autoShow: false })
|
||||
|
||||
let iconName = 'icon.ico';
|
||||
@@ -3575,7 +3644,7 @@ app.whenReady().then(async () => {
|
||||
)
|
||||
|
||||
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
||||
updateSplashProgress(30, '正在加载界面...', true)
|
||||
updateSplashProgress(70, '正在准备主窗口...', true)
|
||||
await new Promise<void>((resolve) => {
|
||||
if (mainWindowReady) {
|
||||
resolve()
|
||||
|
||||
+14
-4
@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onShow: (callback: (event: any, data: any) => void) => {
|
||||
ipcRenderer.on('notification:show', callback)
|
||||
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => {
|
||||
const listener = (_: any, sessionId: string) => callback(sessionId)
|
||||
ipcRenderer.on('navigate-to-session', listener)
|
||||
return () => ipcRenderer.removeListener('navigate-to-session', listener)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -66,7 +71,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
||||
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
||||
},
|
||||
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
|
||||
},
|
||||
|
||||
// 日志
|
||||
@@ -266,15 +270,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
ipcRenderer.invoke('image:decrypt', payload),
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) =>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:resolveCache', payload),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean }
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { allowDecrypt?: boolean }
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
|
||||
@@ -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();
|
||||
@@ -323,6 +323,8 @@ class ChatService {
|
||||
private contactLabelNameMapCacheAt = 0
|
||||
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
||||
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
||||
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
|
||||
private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
|
||||
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
|
||||
private readonly slowGetContactsLogThresholdMs = 1200
|
||||
|
||||
@@ -513,6 +515,43 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
async warmupMessageDbSnapshot(): Promise<{ success: boolean; messageDbCount?: number; mediaDbCount?: number; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||
}
|
||||
|
||||
const [messageSnapshot, mediaResult] = await Promise.all([
|
||||
this.getMessageDbCountSnapshot(true),
|
||||
wcdbService.listMediaDbs()
|
||||
])
|
||||
|
||||
let messageDbCount = 0
|
||||
if (messageSnapshot.success && Array.isArray(messageSnapshot.dbPaths)) {
|
||||
messageDbCount = messageSnapshot.dbPaths.length
|
||||
}
|
||||
|
||||
let mediaDbCount = 0
|
||||
if (mediaResult.success && Array.isArray(mediaResult.data)) {
|
||||
this.mediaDbsCache = [...mediaResult.data]
|
||||
this.mediaDbsCacheTime = Date.now()
|
||||
mediaDbCount = mediaResult.data.length
|
||||
}
|
||||
|
||||
if (!messageSnapshot.success && !mediaResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: messageSnapshot.error || mediaResult.error || '初始化消息库索引失败'
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, messageDbCount, mediaDbCount }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||
if (this.connected && wcdbService.isReady()) {
|
||||
return { success: true }
|
||||
@@ -1362,8 +1401,50 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
private getContactsCacheScope(): string {
|
||||
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
return `${dbPath}::${myWxid}`
|
||||
}
|
||||
|
||||
private cloneContacts(contacts: ContactInfo[]): ContactInfo[] {
|
||||
return (contacts || []).map((contact) => ({
|
||||
...contact,
|
||||
labels: Array.isArray(contact.labels) ? [...contact.labels] : contact.labels
|
||||
}))
|
||||
}
|
||||
|
||||
private getContactsFromMemoryCache(mode: 'lite' | 'full', scope: string): ContactInfo[] | null {
|
||||
const cached = this.contactsMemoryCache.get(mode)
|
||||
if (!cached) return null
|
||||
if (cached.scope !== scope) return null
|
||||
if (Date.now() - cached.updatedAt > this.contactsMemoryCacheTtlMs) return null
|
||||
return this.cloneContacts(cached.contacts)
|
||||
}
|
||||
|
||||
private setContactsMemoryCache(mode: 'lite' | 'full', scope: string, contacts: ContactInfo[]): void {
|
||||
this.contactsMemoryCache.set(mode, {
|
||||
scope,
|
||||
updatedAt: Date.now(),
|
||||
contacts: this.cloneContacts(contacts)
|
||||
})
|
||||
}
|
||||
|
||||
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
||||
const isLiteMode = options?.lite === true
|
||||
const mode: 'lite' | 'full' = isLiteMode ? 'lite' : 'full'
|
||||
const cacheScope = this.getContactsCacheScope()
|
||||
const cachedContacts = this.getContactsFromMemoryCache(mode, cacheScope)
|
||||
if (cachedContacts) {
|
||||
return { success: true, contacts: cachedContacts }
|
||||
}
|
||||
if (isLiteMode) {
|
||||
const fullCachedContacts = this.getContactsFromMemoryCache('full', cacheScope)
|
||||
if (fullCachedContacts) {
|
||||
return { success: true, contacts: fullCachedContacts }
|
||||
}
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
const stageDurations: Array<{ stage: string; ms: number }> = []
|
||||
const captureStage = (stage: string, stageStartedAt: number) => {
|
||||
@@ -1487,6 +1568,10 @@ class ChatService {
|
||||
.join(', ')
|
||||
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
|
||||
}
|
||||
this.setContactsMemoryCache(mode, cacheScope, result)
|
||||
if (!isLiteMode) {
|
||||
this.setContactsMemoryCache('lite', cacheScope, result)
|
||||
}
|
||||
return { success: true, contacts: result }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取通讯录失败:', e)
|
||||
@@ -2886,6 +2971,7 @@ class ChatService {
|
||||
this.sessionTablesCache.clear()
|
||||
this.messageTableColumnsCache.clear()
|
||||
this.messageDbCountSnapshotCache = null
|
||||
this.contactsMemoryCache.clear()
|
||||
this.refreshSessionStatsCacheScope(scope)
|
||||
this.refreshGroupMyMessageCountCacheScope(scope)
|
||||
}
|
||||
@@ -5983,6 +6069,7 @@ class ChatService {
|
||||
if (includeContacts) {
|
||||
this.avatarCache.clear()
|
||||
this.contactCacheService.clear()
|
||||
this.contactsMemoryCache.clear()
|
||||
}
|
||||
|
||||
if (includeMessages) {
|
||||
|
||||
@@ -270,7 +270,9 @@ export class ConfigService {
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
const boolValue = value === true || value === 'true'
|
||||
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
|
||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||
@@ -649,7 +651,7 @@ export class ConfigService {
|
||||
|
||||
clearHelloSecret(): void {
|
||||
this.store.set('authHelloSecret', '' as any)
|
||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
||||
this.store.set('authUseHello', false as any)
|
||||
}
|
||||
|
||||
// === 迁移 ===
|
||||
@@ -658,13 +660,18 @@ export class ConfigService {
|
||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||
const rawEnabled: any = this.store.get('authEnabled')
|
||||
if (typeof rawEnabled === 'boolean') {
|
||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
||||
if (rawEnabled === true || rawEnabled === 'true') {
|
||||
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||
} else if (rawEnabled === false || rawEnabled === 'false') {
|
||||
// 保持 false 为明文布尔,避免冷启动访问 keychain
|
||||
this.store.set('authEnabled', false as any)
|
||||
}
|
||||
|
||||
const rawUseHello: any = this.store.get('authUseHello')
|
||||
if (typeof rawUseHello === 'boolean') {
|
||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
||||
if (rawUseHello === true || rawUseHello === 'true') {
|
||||
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||
} else if (rawUseHello === false || rawUseHello === 'false') {
|
||||
this.store.set('authUseHello', false as any)
|
||||
}
|
||||
|
||||
const rawPassword: any = this.store.get('authPassword')
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface ExportOptions {
|
||||
dateRange?: { start: number; end: number } | null
|
||||
senderUsername?: string
|
||||
fileNameSuffix?: string
|
||||
fileNamingMode?: 'classic' | 'date-range'
|
||||
exportMedia?: boolean
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
@@ -494,6 +495,80 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeExportFileNamePart(value: string): string {
|
||||
return String(value || '')
|
||||
.replace(/[<>:"\/\\|?*]/g, '_')
|
||||
.replace(/\.+$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
|
||||
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
|
||||
}
|
||||
|
||||
private formatDateTokenBySeconds(seconds?: number): string | null {
|
||||
if (!Number.isFinite(seconds) || (seconds || 0) <= 0) return null
|
||||
const date = new Date(Math.floor(Number(seconds)) * 1000)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
const y = date.getFullYear()
|
||||
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const d = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${y}${m}${d}`
|
||||
}
|
||||
|
||||
private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string {
|
||||
const start = this.formatDateTokenBySeconds(dateRange?.start)
|
||||
const end = this.formatDateTokenBySeconds(dateRange?.end)
|
||||
if (start && end) {
|
||||
if (start === end) return start
|
||||
return start < end ? `${start}-${end}` : `${end}-${start}`
|
||||
}
|
||||
if (start) return `${start}-至今`
|
||||
if (end) return `截至-${end}`
|
||||
return '全部时间'
|
||||
}
|
||||
|
||||
private buildSessionExportBaseName(
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
options: ExportOptions
|
||||
): string {
|
||||
const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session'
|
||||
const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '')
|
||||
const namingMode = this.normalizeFileNamingMode(options.fileNamingMode)
|
||||
const parts = [baseName]
|
||||
if (suffix) parts.push(suffix)
|
||||
if (namingMode === 'date-range') {
|
||||
parts.push(this.buildDateRangeFileNamePart(options.dateRange))
|
||||
}
|
||||
return this.sanitizeExportFileNamePart(parts.join('_')) || 'session'
|
||||
}
|
||||
|
||||
private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set<string>): Promise<string> {
|
||||
const dir = path.dirname(preferredPath)
|
||||
const ext = path.extname(preferredPath)
|
||||
const base = path.basename(preferredPath, ext)
|
||||
|
||||
for (let attempt = 0; attempt < 10000; attempt += 1) {
|
||||
const candidate = attempt === 0
|
||||
? preferredPath
|
||||
: path.join(dir, `${base}_${attempt + 1}${ext}`)
|
||||
|
||||
if (reservedPaths.has(candidate)) continue
|
||||
|
||||
const exists = await this.pathExists(candidate)
|
||||
if (reservedPaths.has(candidate)) continue
|
||||
if (exists) continue
|
||||
|
||||
reservedPaths.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
|
||||
const fallback = path.join(dir, `${base}_${Date.now()}${ext}`)
|
||||
reservedPaths.add(fallback)
|
||||
return fallback
|
||||
}
|
||||
|
||||
private isCloneUnsupportedError(code: string | undefined): boolean {
|
||||
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
|
||||
}
|
||||
@@ -8911,6 +8986,7 @@ class ExportService {
|
||||
? path.join(outputDir, 'texts')
|
||||
: outputDir
|
||||
const createdTaskDirs = new Set<string>()
|
||||
const reservedOutputPaths = new Set<string>()
|
||||
const ensureTaskDir = async (dirPath: string) => {
|
||||
if (createdTaskDirs.has(dirPath)) return
|
||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||
@@ -9159,10 +9235,8 @@ class ExportService {
|
||||
phaseLabel: '准备导出'
|
||||
})
|
||||
|
||||
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
||||
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
||||
const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '')
|
||||
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
||||
const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode)
|
||||
const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
|
||||
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
|
||||
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : ''
|
||||
const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
|
||||
@@ -9180,13 +9254,13 @@ class ExportService {
|
||||
else if (effectiveOptions.format === 'txt') ext = '.txt'
|
||||
else if (effectiveOptions.format === 'weclone') ext = '.csv'
|
||||
else if (effectiveOptions.format === 'html') ext = '.html'
|
||||
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||
const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
|
||||
typeof messageCountHint === 'number' &&
|
||||
messageCountHint >= 0 &&
|
||||
typeof latestTimestampHint === 'number' &&
|
||||
latestTimestampHint > 0 &&
|
||||
await this.pathExists(outputPath)
|
||||
await this.pathExists(preferredOutputPath)
|
||||
if (canTrySkipUnchanged) {
|
||||
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
|
||||
const hasNoDataChange = Boolean(
|
||||
@@ -9213,6 +9287,10 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
|
||||
const outputPath = fileNamingMode === 'date-range'
|
||||
? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
|
||||
: preferredOutputPath
|
||||
|
||||
let result: { success: boolean; error?: string }
|
||||
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
|
||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||
|
||||
@@ -63,6 +63,7 @@ type CachedImagePayload = {
|
||||
imageDatName?: string
|
||||
preferFilePath?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
@@ -116,7 +117,9 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
await this.ensureCacheIndexed()
|
||||
if (payload.allowCacheIndex !== false) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
@@ -673,41 +676,53 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||
if (!allowThumbnail) {
|
||||
return null
|
||||
}
|
||||
const searchNames = Array.from(
|
||||
new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (searchNames.length === 0) return null
|
||||
|
||||
if (!imageDatName) return null
|
||||
if (!skipResolvedCache) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
for (const searchName of searchNames) {
|
||||
const cached = this.resolvedCache.get(searchName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath })
|
||||
this.resolvedCache.set(imageDatName, datPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
return datPath
|
||||
}
|
||||
const normalized = this.normalizeDatBase(imageDatName)
|
||||
if (normalized !== imageDatName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath })
|
||||
this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
return normalizedPath
|
||||
for (const searchName of searchNames) {
|
||||
const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
|
||||
this.cacheDatPath(accountDir, searchName, datPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
|
||||
return datPath
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized })
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const normalized = this.normalizeDatBase(searchName)
|
||||
if (normalized !== searchName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath)
|
||||
this.cacheDatPath(accountDir, searchName, normalizedPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath)
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1042,7 +1057,7 @@ export class ImageDecryptService {
|
||||
|
||||
private stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -1058,8 +1073,10 @@ export class ImageDecryptService {
|
||||
const lower = name.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!this.hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (this.isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
@@ -1070,9 +1087,13 @@ export class ImageDecryptService {
|
||||
const names = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_hd.dat`,
|
||||
`${baseName}.hd.dat`,
|
||||
`${baseName}_b.dat`,
|
||||
`${baseName}.b.dat`,
|
||||
`${baseName}_w.dat`,
|
||||
`${baseName}.w.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_c.dat`,
|
||||
`${baseName}.c.dat`
|
||||
]
|
||||
|
||||
@@ -8,11 +8,13 @@ type PreloadImagePayload = {
|
||||
|
||||
type PreloadOptions = {
|
||||
allowDecrypt?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type PreloadTask = PreloadImagePayload & {
|
||||
key: string
|
||||
allowDecrypt: boolean
|
||||
allowCacheIndex: boolean
|
||||
}
|
||||
|
||||
export class ImagePreloadService {
|
||||
@@ -27,6 +29,7 @@ export class ImagePreloadService {
|
||||
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||
const allowDecrypt = options?.allowDecrypt !== false
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
for (const payload of payloads) {
|
||||
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
@@ -34,7 +37,7 @@ export class ImagePreloadService {
|
||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||
if (this.pending.has(key)) continue
|
||||
this.pending.add(key)
|
||||
this.queue.push({ ...payload, key, allowDecrypt })
|
||||
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||
}
|
||||
this.processQueue()
|
||||
}
|
||||
@@ -71,7 +74,8 @@ export class ImagePreloadService {
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
disableUpdateCheck: !task.allowDecrypt
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
|
||||
@@ -15,10 +15,8 @@
|
||||
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { URL } from 'url'
|
||||
import { app, Notification } from 'electron'
|
||||
import { Notification } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, ChatSession, Message } from './chatService'
|
||||
|
||||
@@ -38,6 +36,13 @@ const API_TIMEOUT_MS = 45_000
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
const INSIGHT_CONFIG_KEYS = new Set([
|
||||
'aiInsightEnabled',
|
||||
'aiInsightScanIntervalHours',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
])
|
||||
|
||||
// ─── 类型 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,33 +51,17 @@ interface TodayTriggerRecord {
|
||||
timestamps: number[]
|
||||
}
|
||||
|
||||
// ─── 桌面日志 ─────────────────────────────────────────────────────────────────
|
||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将日志同时输出到 console 和桌面上的 weflow-insight.log 文件。
|
||||
* 文件名带当天日期,每天自动换一个新文件,旧文件保留。
|
||||
* 仅输出到 console,不落盘到文件。
|
||||
*/
|
||||
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
|
||||
const now = new Date()
|
||||
const dateStr = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
|
||||
const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
const line = `[${dateStr} ${timeStr}] [${level}] ${message}\n`
|
||||
|
||||
// 同步到 console
|
||||
if (level === 'ERROR' || level === 'WARN') {
|
||||
console.warn(`[InsightService] ${message}`)
|
||||
} else {
|
||||
console.log(`[InsightService] ${message}`)
|
||||
}
|
||||
|
||||
// 异步写入桌面日志文件,避免同步磁盘 I/O 阻塞 Electron 主线程事件循环
|
||||
try {
|
||||
const desktopPath = app.getPath('desktop')
|
||||
const logFile = path.join(desktopPath, `weflow-insight-${dateStr}.log`)
|
||||
fs.appendFile(logFile, line, 'utf-8', () => { /* 失败静默处理 */ })
|
||||
} catch {
|
||||
// getPath 失败时静默处理
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
@@ -234,15 +223,64 @@ class InsightService {
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
insightLog('INFO', '已启动')
|
||||
this.scheduleSilenceScan()
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const hadActiveFlow =
|
||||
this.dbDebounceTimer !== null ||
|
||||
this.silenceScanTimer !== null ||
|
||||
this.silenceInitialDelayTimer !== null ||
|
||||
this.processing
|
||||
this.started = false
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
if (hadActiveFlow) {
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
}
|
||||
|
||||
async handleConfigChanged(key: string): Promise<void> {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
|
||||
|
||||
// 数据库相关配置变更后,丢弃缓存并强制下次重连
|
||||
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||
this.clearRuntimeCache()
|
||||
}
|
||||
|
||||
await this.refreshConfiguration(`config:${normalizedKey}`)
|
||||
}
|
||||
|
||||
handleConfigCleared(): void {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
}
|
||||
|
||||
private async refreshConfiguration(_reason: string): Promise<void> {
|
||||
if (!this.started) return
|
||||
if (!this.isEnabled()) {
|
||||
this.clearTimers()
|
||||
this.clearRuntimeCache()
|
||||
this.processing = false
|
||||
return
|
||||
}
|
||||
this.scheduleSilenceScan()
|
||||
}
|
||||
|
||||
private clearRuntimeCache(): void {
|
||||
this.dbConnected = false
|
||||
this.sessionCache = null
|
||||
this.sessionCacheAt = 0
|
||||
this.lastActivityAnalysis.clear()
|
||||
this.lastSeenTimestamp.clear()
|
||||
this.todayTriggers.clear()
|
||||
this.todayDate = getStartOfDay()
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
this.dbDebounceTimer = null
|
||||
@@ -255,7 +293,6 @@ class InsightService {
|
||||
clearTimeout(this.silenceInitialDelayTimer)
|
||||
this.silenceInitialDelayTimer = null
|
||||
}
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -452,9 +489,12 @@ class InsightService {
|
||||
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSilenceScan(): void {
|
||||
this.clearTimers()
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
|
||||
// 等待扫描完成后再安排下一次,避免并发堆积
|
||||
const scheduleNext = () => {
|
||||
if (!this.started) return
|
||||
if (!this.started || !this.isEnabled()) return
|
||||
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
|
||||
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
|
||||
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
|
||||
@@ -474,7 +514,6 @@ class InsightService {
|
||||
|
||||
private async runSilenceScan(): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
insightLog('INFO', '沉默扫描:AI 见解未启用,跳过')
|
||||
return
|
||||
}
|
||||
if (this.processing) {
|
||||
@@ -502,6 +541,7 @@ class InsightService {
|
||||
|
||||
let silentCount = 0
|
||||
for (const session of sessions) {
|
||||
if (!this.isEnabled()) return
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
if (sessionId.toLowerCase().includes('placeholder')) continue
|
||||
@@ -654,6 +694,7 @@ class InsightService {
|
||||
}): Promise<void> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
if (!sessionId) return
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||
@@ -747,6 +788,7 @@ class InsightService {
|
||||
insightLog('INFO', `模型选择跳过 ${displayName}`)
|
||||
return
|
||||
}
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${displayName}`
|
||||
|
||||
@@ -61,6 +61,7 @@ export class KeyService {
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
@@ -68,11 +69,20 @@ export class KeyService {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
|
||||
@@ -25,13 +25,23 @@ export class KeyServiceLinux {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||
} else {
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||
}
|
||||
for (const p of candidates) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, shell } from 'electron'
|
||||
import { join, basename, dirname } from 'path'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
@@ -27,6 +27,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_HELPER_PATH) {
|
||||
@@ -34,12 +35,21 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
|
||||
}
|
||||
|
||||
@@ -52,14 +62,24 @@ export class KeyServiceMac {
|
||||
|
||||
private getImageScanHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
|
||||
}
|
||||
|
||||
@@ -72,6 +92,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getDylibPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DYLIB_PATH) {
|
||||
@@ -79,11 +100,20 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
|
||||
}
|
||||
|
||||
@@ -373,19 +403,71 @@ export class KeyServiceMac {
|
||||
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] {
|
||||
const baseDir = dirname(primaryBinaryPath)
|
||||
const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib']
|
||||
const unique: string[] = []
|
||||
for (const name of names) {
|
||||
const full = join(baseDir, name)
|
||||
if (!existsSync(full)) continue
|
||||
if (!unique.includes(full)) unique.push(full)
|
||||
}
|
||||
if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) {
|
||||
unique.unshift(primaryBinaryPath)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
private ensureExecutableBitsBestEffort(paths: string[]): void {
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const mode = statSync(p).mode
|
||||
if ((mode & 0o111) !== 0) continue
|
||||
chmodSync(p, mode | 0o111)
|
||||
} catch {
|
||||
// ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise<void> {
|
||||
const existing = paths.filter(p => existsSync(p))
|
||||
if (existing.length === 0) return
|
||||
|
||||
const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ')
|
||||
const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000))
|
||||
const scriptLines = [
|
||||
`set chmodCmd to "/bin/chmod +x ${quotedPaths}"`,
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'with timeout of timeoutSec seconds',
|
||||
'do shell script chmodCmd with administrator privileges',
|
||||
'end timeout'
|
||||
]
|
||||
|
||||
await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||
timeout: timeoutMs + 10_000
|
||||
})
|
||||
}
|
||||
|
||||
private async getDbKeyByHelperElevated(
|
||||
timeoutMs: number,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<string> {
|
||||
const helperPath = this.getHelperPath()
|
||||
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||
const waitMs = Math.max(timeoutMs, 30_000)
|
||||
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
||||
const pid = await this.getWeChatPid()
|
||||
const chmodPart = artifactPaths.length > 0
|
||||
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}`
|
||||
: ''
|
||||
const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}`
|
||||
const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart
|
||||
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
`set cmd to ${JSON.stringify(privilegedCmd)}`,
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
@@ -721,10 +803,12 @@ export class KeyServiceMac {
|
||||
try {
|
||||
const helperPath = this.getImageScanHelperPath()
|
||||
const ciphertextHex = ciphertext.toString('hex')
|
||||
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||
|
||||
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
||||
if (!this._needsElevation) {
|
||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
|
||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths)
|
||||
if (direct.key) return direct.key
|
||||
if (direct.permissionError) {
|
||||
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
||||
@@ -735,7 +819,12 @@ export class KeyServiceMac {
|
||||
|
||||
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
||||
if (this._needsElevation) {
|
||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
|
||||
try {
|
||||
await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000)
|
||||
} catch (e: any) {
|
||||
console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e)
|
||||
}
|
||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths)
|
||||
if (elevated.key) return elevated.key
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -838,12 +927,19 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
private _spawnScanHelper(
|
||||
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
|
||||
helperPath: string,
|
||||
pid: number,
|
||||
ciphertextHex: string,
|
||||
elevated: boolean,
|
||||
artifactPaths: string[] = []
|
||||
): Promise<{ key: string | null; permissionError: boolean }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child: ReturnType<typeof spawn>
|
||||
if (elevated) {
|
||||
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
|
||||
const chmodPart = artifactPaths.length > 0
|
||||
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && `
|
||||
: ''
|
||||
const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}`
|
||||
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import dbus from "dbus-native";
|
||||
import https from "https";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import { join } from "path";
|
||||
import { app } from "electron";
|
||||
|
||||
const BUS_NAME = "org.freedesktop.Notifications";
|
||||
const OBJECT_PATH = "/org/freedesktop/Notifications";
|
||||
import { Notification } from "electron";
|
||||
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||
|
||||
export interface LinuxNotificationData {
|
||||
sessionId?: string;
|
||||
@@ -18,173 +11,96 @@ export interface LinuxNotificationData {
|
||||
|
||||
type NotificationCallback = (sessionId: string) => void;
|
||||
|
||||
let sessionBus: dbus.DBusConnection | null = null;
|
||||
let notificationCallbacks: NotificationCallback[] = [];
|
||||
let pendingNotifications: Map<number, LinuxNotificationData> = new Map();
|
||||
let notificationCounter = 1;
|
||||
const activeNotifications: Map<number, Notification> = new Map();
|
||||
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
|
||||
|
||||
// 头像缓存:url->localFilePath
|
||||
const avatarCache: Map<string, string> = new Map();
|
||||
// 缓存目录
|
||||
let avatarCacheDir: string | null = null;
|
||||
|
||||
async function getSessionBus(): Promise<dbus.DBusConnection> {
|
||||
if (!sessionBus) {
|
||||
sessionBus = dbus.sessionBus();
|
||||
|
||||
// 挂载底层socket的error事件,防止掉线即可
|
||||
sessionBus.connection.on("error", (err: Error) => {
|
||||
console.error("[LinuxNotification] D-Bus connection error:", err);
|
||||
sessionBus = null; // 报错清理死对象
|
||||
});
|
||||
}
|
||||
return sessionBus;
|
||||
function nextNotificationId(): number {
|
||||
const id = notificationCounter;
|
||||
notificationCounter += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
// 确保缓存目录存在
|
||||
async function ensureCacheDir(): Promise<string> {
|
||||
if (!avatarCacheDir) {
|
||||
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars");
|
||||
function clearNotificationState(notificationId: number): void {
|
||||
activeNotifications.delete(notificationId);
|
||||
const timer = closeTimers.get(notificationId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
closeTimers.delete(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
await fs.mkdir(avatarCacheDir, { recursive: true });
|
||||
callback(sessionId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[LinuxNotification] Failed to create avatar cache dir:",
|
||||
error,
|
||||
);
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
}
|
||||
}
|
||||
return avatarCacheDir;
|
||||
}
|
||||
|
||||
// 下载头像到本地临时文件
|
||||
async function downloadAvatarToLocal(url: string): Promise<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(
|
||||
data: LinuxNotificationData,
|
||||
): Promise<number | null> {
|
||||
if (process.platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bus = await getSessionBus();
|
||||
|
||||
const appName = "WeFlow";
|
||||
const replaceId = 0;
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
|
||||
// 处理头像:下载到本地或使用URL
|
||||
let appIcon = "";
|
||||
let hints: any[] = [];
|
||||
let iconPath: string | undefined;
|
||||
if (data.avatarUrl) {
|
||||
// 优先尝试下载到本地
|
||||
const localPath = await downloadAvatarToLocal(data.avatarUrl);
|
||||
if (localPath) {
|
||||
hints = [["image-path", ["s", localPath]]];
|
||||
}
|
||||
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
bus.invoke(
|
||||
{
|
||||
destination: BUS_NAME,
|
||||
path: OBJECT_PATH,
|
||||
interface: "org.freedesktop.Notifications",
|
||||
member: "Notify",
|
||||
signature: "susssasa{sv}i",
|
||||
body: [
|
||||
appName,
|
||||
replaceId,
|
||||
appIcon,
|
||||
data.title,
|
||||
data.content,
|
||||
["default", "打开"], // 提供default action,否则系统不会抛出点击事件
|
||||
hints,
|
||||
// [], // 传空数组以避开a{sv}变体的序列化崩溃,有pendingNotifications映射维护保证不出错
|
||||
expireTimeout,
|
||||
],
|
||||
},
|
||||
(err: Error | null, result: any) => {
|
||||
if (err) {
|
||||
console.error("[LinuxNotification] Notify error:", err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
const notificationId =
|
||||
typeof result === "number" ? result : result[0];
|
||||
if (data.sessionId) {
|
||||
// 依赖Map实现点击追踪,没有使用D-Bus hints
|
||||
pendingNotifications.set(notificationId, data);
|
||||
}
|
||||
console.log(
|
||||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}, icon: ${appIcon || "none"}`,
|
||||
);
|
||||
resolve(notificationId);
|
||||
},
|
||||
);
|
||||
const notification = new Notification({
|
||||
title: data.title,
|
||||
body: data.content,
|
||||
icon: iconPath,
|
||||
});
|
||||
|
||||
const notificationId = nextNotificationId();
|
||||
activeNotifications.set(notificationId, notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
if (data.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
notification.on("failed", (_, error) => {
|
||||
console.error("[LinuxNotification] Notification failed:", error);
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
if (expireTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
const currentNotification = activeNotifications.get(notificationId);
|
||||
if (currentNotification) {
|
||||
currentNotification.close();
|
||||
}
|
||||
}, expireTimeout);
|
||||
closeTimers.set(notificationId, timer);
|
||||
}
|
||||
|
||||
notification.show();
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||
);
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to show notification:", error);
|
||||
return null;
|
||||
@@ -194,59 +110,22 @@ export async function showLinuxNotification(
|
||||
export async function closeLinuxNotification(
|
||||
notificationId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const bus = await getSessionBus();
|
||||
return new Promise((resolve, reject) => {
|
||||
bus.invoke(
|
||||
{
|
||||
destination: BUS_NAME,
|
||||
path: OBJECT_PATH,
|
||||
interface: "org.freedesktop.Notifications",
|
||||
member: "CloseNotification",
|
||||
signature: "u",
|
||||
body: [notificationId],
|
||||
},
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
console.error("[LinuxNotification] CloseNotification error:", err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
pendingNotifications.delete(notificationId);
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to close notification:", error);
|
||||
}
|
||||
const notification = activeNotifications.get(notificationId);
|
||||
if (!notification) return;
|
||||
notification.close();
|
||||
clearNotificationState(notificationId);
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<string[]> {
|
||||
try {
|
||||
const bus = await getSessionBus();
|
||||
return new Promise((resolve, reject) => {
|
||||
bus.invoke(
|
||||
{
|
||||
destination: BUS_NAME,
|
||||
path: OBJECT_PATH,
|
||||
interface: "org.freedesktop.Notifications",
|
||||
member: "GetCapabilities",
|
||||
},
|
||||
(err: Error | null, result: any) => {
|
||||
if (err) {
|
||||
console.error("[LinuxNotification] GetCapabilities error:", err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(result as string[]);
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to get capabilities:", error);
|
||||
if (process.platform !== "linux") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["native-notification", "click"];
|
||||
}
|
||||
|
||||
export function onNotificationAction(callback: NotificationCallback): void {
|
||||
@@ -262,83 +141,34 @@ export function removeNotificationCallback(
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
callback(sessionId);
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initLinuxNotificationService(): Promise<void> {
|
||||
if (process.platform !== "linux") {
|
||||
console.log("[LinuxNotification] Not on Linux, skipping init");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bus = await getSessionBus();
|
||||
|
||||
// 监听底层connection的message事件
|
||||
bus.connection.on("message", (msg: any) => {
|
||||
// type 4表示SIGNAL
|
||||
if (
|
||||
msg.type === 4 &&
|
||||
msg.path === OBJECT_PATH &&
|
||||
msg.interface === "org.freedesktop.Notifications"
|
||||
) {
|
||||
if (msg.member === "ActionInvoked") {
|
||||
const [notificationId, actionId] = msg.body;
|
||||
console.log(
|
||||
`[LinuxNotification] Action invoked: ${notificationId}, ${actionId}`,
|
||||
);
|
||||
|
||||
// 如果用户点击了通知本体,actionId会是'default'
|
||||
if (actionId === "default") {
|
||||
const data = pendingNotifications.get(notificationId);
|
||||
if (data?.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.member === "NotificationClosed") {
|
||||
const [notificationId] = msg.body;
|
||||
pendingNotifications.delete(notificationId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// AddMatch用来接收信号
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
bus.invoke(
|
||||
{
|
||||
destination: "org.freedesktop.DBus",
|
||||
path: "/org/freedesktop/DBus",
|
||||
interface: "org.freedesktop.DBus",
|
||||
member: "AddMatch",
|
||||
signature: "s",
|
||||
body: ["type='signal',interface='org.freedesktop.Notifications'"],
|
||||
},
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
console.error("[LinuxNotification] AddMatch error:", err);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
console.log("[LinuxNotification] Service initialized");
|
||||
|
||||
// 打印相关日志
|
||||
const caps = await getCapabilities();
|
||||
console.log("[LinuxNotification] Server capabilities:", caps);
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to initialize:", error);
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = await getCapabilities();
|
||||
console.log("[LinuxNotification] Service initialized with native API:", caps);
|
||||
}
|
||||
|
||||
export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||
// 清理所有活动的通知
|
||||
for (const [id, notification] of activeNotifications) {
|
||||
try {
|
||||
notification.close();
|
||||
} catch {}
|
||||
clearNotificationState(id);
|
||||
}
|
||||
|
||||
// 清理头像文件缓存
|
||||
try {
|
||||
await avatarFileCache.clearCache();
|
||||
} catch {}
|
||||
|
||||
console.log("[LinuxNotification] Service shutdown complete");
|
||||
}
|
||||
+126
-55
@@ -121,6 +121,9 @@ export class WcdbCore {
|
||||
private videoHardlinkCache: Map<string, { result: { success: boolean; data?: any; error?: string }; updatedAt: number }> = new Map()
|
||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly hardlinkCacheMaxEntries = 20000
|
||||
private mediaStreamSessionCache: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> | null = null
|
||||
private mediaStreamSessionCacheAt = 0
|
||||
private readonly mediaStreamSessionCacheTtlMs = 12 * 1000
|
||||
private logTimer: NodeJS.Timeout | null = null
|
||||
private lastLogTail: string | null = null
|
||||
private lastResolvedLogPath: string | null = null
|
||||
@@ -277,7 +280,9 @@ export class WcdbCore {
|
||||
const isLinux = process.platform === 'linux'
|
||||
const isArm64 = process.arch === 'arm64'
|
||||
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
|
||||
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
|
||||
const legacySubDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
|
||||
const platformDir = isMac ? 'macos' : (isLinux ? 'linux' : 'win32')
|
||||
const archDir = isMac ? 'universal' : (isArm64 ? 'arm64' : 'x64')
|
||||
|
||||
const envDllPath = process.env.WCDB_DLL_PATH
|
||||
if (envDllPath && envDllPath.length > 0) {
|
||||
@@ -287,20 +292,33 @@ export class WcdbCore {
|
||||
// 基础路径探测
|
||||
const isPackaged = typeof process['resourcesPath'] !== 'undefined'
|
||||
const resourcesPath = isPackaged ? process.resourcesPath : join(process.cwd(), 'resources')
|
||||
|
||||
const candidates = [
|
||||
// 环境变量指定 resource 目录
|
||||
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null,
|
||||
// 显式 setPaths 设置的路径
|
||||
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null,
|
||||
// resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
|
||||
join(resourcesPath, 'resources', subDir, libName),
|
||||
// resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构)
|
||||
join(resourcesPath, subDir, libName),
|
||||
// CWD fallback
|
||||
join(process.cwd(), 'resources', subDir, libName)
|
||||
const roots = [
|
||||
process.env.WCDB_RESOURCES_PATH || null,
|
||||
this.resourcesPath || null,
|
||||
join(resourcesPath, 'resources'),
|
||||
resourcesPath,
|
||||
join(process.cwd(), 'resources')
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
const normalizedArch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const relativeCandidates = [
|
||||
join('wcdb', platformDir, archDir, libName),
|
||||
join('wcdb', platformDir, normalizedArch, libName),
|
||||
join('wcdb', platformDir, 'x64', libName),
|
||||
join('wcdb', platformDir, 'universal', libName),
|
||||
join('wcdb', platformDir, libName)
|
||||
]
|
||||
|
||||
const candidates: string[] = []
|
||||
for (const root of roots) {
|
||||
for (const relativePath of relativeCandidates) {
|
||||
candidates.push(join(root, relativePath))
|
||||
}
|
||||
// 兼容旧目录:resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
|
||||
candidates.push(join(root, legacySubDir, libName))
|
||||
candidates.push(join(root, libName))
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
@@ -1465,6 +1483,11 @@ export class WcdbCore {
|
||||
this.videoHardlinkCache.clear()
|
||||
}
|
||||
|
||||
private clearMediaStreamSessionCache(): void {
|
||||
this.mediaStreamSessionCache = null
|
||||
this.mediaStreamSessionCacheAt = 0
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ensureReady()
|
||||
}
|
||||
@@ -1580,6 +1603,7 @@ export class WcdbCore {
|
||||
this.currentDbStoragePath = null
|
||||
this.initialized = false
|
||||
this.clearHardlinkCaches()
|
||||
this.clearMediaStreamSessionCache()
|
||||
this.stopLogPolling()
|
||||
}
|
||||
}
|
||||
@@ -1957,7 +1981,7 @@ export class WcdbCore {
|
||||
error?: string
|
||||
}> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持媒体流扫描,请先更新 wcdb 数据服务' }
|
||||
if (!this.wcdbScanMediaStream) return { success: false, error: '当前数据服务版本不支持资源扫描,请先更新 wcdb 数据服务' }
|
||||
try {
|
||||
const toInt = (value: unknown): number => {
|
||||
const n = Number(value || 0)
|
||||
@@ -2168,37 +2192,64 @@ export class WcdbCore {
|
||||
const offset = Math.max(0, toInt(options?.offset))
|
||||
const limit = Math.min(1200, Math.max(40, toInt(options?.limit) || 240))
|
||||
|
||||
const sessionsRes = await this.getSessions()
|
||||
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
|
||||
return { success: false, error: sessionsRes.error || '读取会话失败' }
|
||||
const getSessionRows = async (): Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ sessionId: string; displayName: string; sortTimestamp: number }>
|
||||
error?: string
|
||||
}> => {
|
||||
const now = Date.now()
|
||||
const cachedRows = this.mediaStreamSessionCache
|
||||
if (
|
||||
cachedRows &&
|
||||
now - this.mediaStreamSessionCacheAt <= this.mediaStreamSessionCacheTtlMs
|
||||
) {
|
||||
return { success: true, rows: cachedRows }
|
||||
}
|
||||
|
||||
const sessionsRes = await this.getSessions()
|
||||
if (!sessionsRes.success || !Array.isArray(sessionsRes.sessions)) {
|
||||
return { success: false, error: sessionsRes.error || '读取会话失败' }
|
||||
}
|
||||
|
||||
const rows = (sessionsRes.sessions || [])
|
||||
.map((row: any) => ({
|
||||
sessionId: String(
|
||||
row.username ||
|
||||
row.user_name ||
|
||||
row.userName ||
|
||||
row.usrName ||
|
||||
row.UsrName ||
|
||||
row.talker ||
|
||||
''
|
||||
).trim(),
|
||||
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
|
||||
sortTimestamp: toInt(
|
||||
row.sort_timestamp ||
|
||||
row.sortTimestamp ||
|
||||
row.last_timestamp ||
|
||||
row.lastTimestamp ||
|
||||
0
|
||||
)
|
||||
}))
|
||||
.filter((row) => Boolean(row.sessionId))
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
|
||||
|
||||
this.mediaStreamSessionCache = rows
|
||||
this.mediaStreamSessionCacheAt = now
|
||||
return { success: true, rows }
|
||||
}
|
||||
|
||||
const sessions = (sessionsRes.sessions || [])
|
||||
.map((row: any) => ({
|
||||
sessionId: String(
|
||||
row.username ||
|
||||
row.user_name ||
|
||||
row.userName ||
|
||||
row.usrName ||
|
||||
row.UsrName ||
|
||||
row.talker ||
|
||||
''
|
||||
).trim(),
|
||||
displayName: String(row.displayName || row.display_name || row.remark || '').trim(),
|
||||
sortTimestamp: toInt(
|
||||
row.sort_timestamp ||
|
||||
row.sortTimestamp ||
|
||||
row.last_timestamp ||
|
||||
row.lastTimestamp ||
|
||||
0
|
||||
)
|
||||
}))
|
||||
.filter((row) => Boolean(row.sessionId))
|
||||
.sort((a, b) => b.sortTimestamp - a.sortTimestamp)
|
||||
let sessionRows: Array<{ sessionId: string; displayName: string; sortTimestamp: number }> = []
|
||||
if (requestedSessionId) {
|
||||
sessionRows = [{ sessionId: requestedSessionId, displayName: requestedSessionId, sortTimestamp: 0 }]
|
||||
} else {
|
||||
const sessionsRowsRes = await getSessionRows()
|
||||
if (!sessionsRowsRes.success || !Array.isArray(sessionsRowsRes.rows)) {
|
||||
return { success: false, error: sessionsRowsRes.error || '读取会话失败' }
|
||||
}
|
||||
sessionRows = sessionsRowsRes.rows
|
||||
}
|
||||
|
||||
const sessionRows = requestedSessionId
|
||||
? sessions.filter((row) => row.sessionId === requestedSessionId)
|
||||
: sessions
|
||||
if (sessionRows.length === 0) {
|
||||
return { success: true, items: [], hasMore: false, nextOffset: offset }
|
||||
}
|
||||
@@ -2219,10 +2270,10 @@ export class WcdbCore {
|
||||
outHasMore
|
||||
)
|
||||
if (result !== 0 || !outPtr[0]) {
|
||||
return { success: false, error: `扫描媒体流失败: ${result}` }
|
||||
return { success: false, error: `扫描资源失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析媒体流失败' }
|
||||
if (!jsonStr) return { success: false, error: '解析资源失败' }
|
||||
const rows = JSON.parse(jsonStr)
|
||||
const list = Array.isArray(rows) ? rows as Array<Record<string, any>> : []
|
||||
|
||||
@@ -2254,19 +2305,39 @@ export class WcdbCore {
|
||||
rawMessageContent &&
|
||||
(rawMessageContent.includes('<') || rawMessageContent.includes('md5') || rawMessageContent.includes('videomsg'))
|
||||
)
|
||||
const content = useRawMessageContent
|
||||
? rawMessageContent
|
||||
: decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||
const decodeContentIfNeeded = (): string => {
|
||||
if (useRawMessageContent) return rawMessageContent
|
||||
if (!rawMessageContent && !rawCompressContent) return ''
|
||||
return decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||
}
|
||||
const packedPayload = extractPackedPayload(row)
|
||||
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
|
||||
const imageMd5 = localType === 3
|
||||
? (imageMd5ByColumn || extractImageMd5(content) || extractHexMd5(packedPayload) || undefined)
|
||||
: undefined
|
||||
const imageDatName = localType === 3 ? (extractImageDatName(row, content) || undefined) : undefined
|
||||
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
|
||||
const videoMd5 = localType === 43
|
||||
? (videoMd5ByColumn || extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined)
|
||||
: undefined
|
||||
|
||||
let content = ''
|
||||
let imageMd5: string | undefined
|
||||
let imageDatName: string | undefined
|
||||
let videoMd5: string | undefined
|
||||
|
||||
if (localType === 3) {
|
||||
imageMd5 = imageMd5ByColumn || extractHexMd5(packedPayload) || undefined
|
||||
imageDatName = extractImageDatName(row, '') || undefined
|
||||
if (!imageMd5 || !imageDatName) {
|
||||
content = decodeContentIfNeeded()
|
||||
if (!imageMd5) imageMd5 = extractImageMd5(content) || extractHexMd5(packedPayload) || undefined
|
||||
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
|
||||
}
|
||||
} else if (localType === 43) {
|
||||
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
|
||||
if (!videoMd5) {
|
||||
content = decodeContentIfNeeded()
|
||||
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
|
||||
} else if (useRawMessageContent) {
|
||||
// 占位态标题只依赖简单 XML,已带 md5 时不做额外解压
|
||||
content = rawMessageContent
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
sessionDisplayName: sessionNameMap.get(sessionId) || sessionId,
|
||||
@@ -2280,7 +2351,7 @@ export class WcdbCore {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
videoMd5,
|
||||
content: content || undefined
|
||||
content: localType === 43 ? (content || undefined) : undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Vendored
-18
@@ -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;
|
||||
}
|
||||
@@ -27,6 +27,14 @@ export function destroyNotificationWindow() {
|
||||
}
|
||||
lastNotificationData = null;
|
||||
|
||||
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
|
||||
if (isLinux && linuxNotificationService) {
|
||||
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
|
||||
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
|
||||
});
|
||||
linuxNotificationService = null;
|
||||
}
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null;
|
||||
return;
|
||||
|
||||
Generated
+1
-210
@@ -10,7 +10,6 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"dbus-native": "^0.4.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -45,7 +44,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
}
|
||||
},
|
||||
@@ -3084,25 +3083,6 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abstract-socket": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abstract-socket/-/abstract-socket-2.1.1.tgz",
|
||||
"integrity": "sha512-YZJizsvS1aBua5Gd01woe4zuyYBGgSMeqDOB6/ChwdTI904KP6QGtJswXl4hcqWxbz86hQBe++HWV0hF1aGUtA==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"dependencies": {
|
||||
"bindings": "^1.2.1",
|
||||
"nan": "^2.12.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -3615,16 +3595,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -4459,27 +4429,6 @@
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dbus-native": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dbus-native/-/dbus-native-0.4.0.tgz",
|
||||
"integrity": "sha512-i3zvY3tdPEOaMgmK4riwupjDYRJ53rcE1Kj8rAgnLOFmBd0DekUih59qv8v+Oyils/U9p+s4sSsaBzHWLztI+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-stream": "^4.0.0",
|
||||
"hexy": "^0.2.10",
|
||||
"long": "^4.0.0",
|
||||
"optimist": "^0.6.1",
|
||||
"put": "0.0.6",
|
||||
"safe-buffer": "^5.1.1",
|
||||
"xml2js": "^0.4.17"
|
||||
},
|
||||
"bin": {
|
||||
"dbus2js": "bin/dbus2js.js"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"abstract-socket": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/debounce-fn": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz",
|
||||
@@ -4848,12 +4797,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
|
||||
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
@@ -5379,21 +5322,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/event-stream": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz",
|
||||
"integrity": "sha512-qACXdu/9VHPBzcyhdOWR5/IahhGMf0roTeZJfzz077GwylcDd90yOHLouhmv7GJ5XzPi6ekaQWd8AvPP2nOvpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexer": "^0.1.1",
|
||||
"from": "^0.1.7",
|
||||
"map-stream": "0.0.7",
|
||||
"pause-stream": "^0.0.11",
|
||||
"split": "^1.0.1",
|
||||
"stream-combiner": "^0.2.2",
|
||||
"through": "^2.3.8"
|
||||
}
|
||||
},
|
||||
"node_modules/exceljs": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz",
|
||||
@@ -5570,13 +5498,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||
@@ -5664,12 +5585,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/from": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
|
||||
"integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
@@ -6069,15 +5984,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hexy": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/hexy/-/hexy-0.2.11.tgz",
|
||||
"integrity": "sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"hexy": "bin/hexy_cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
@@ -6806,12 +6712,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
|
||||
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/longest-streak": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
||||
@@ -6874,12 +6774,6 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/map-stream": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz",
|
||||
"integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -8023,13 +7917,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.26.2",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
|
||||
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -8222,22 +8109,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/optimist": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
|
||||
"integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==",
|
||||
"license": "MIT/X11",
|
||||
"dependencies": {
|
||||
"minimist": "~0.0.1",
|
||||
"wordwrap": "~0.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/optimist/node_modules/minimist": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz",
|
||||
"integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ora": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
|
||||
@@ -8387,18 +8258,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pause-stream": {
|
||||
"version": "0.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
|
||||
"integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==",
|
||||
"license": [
|
||||
"MIT",
|
||||
"Apache2"
|
||||
],
|
||||
"dependencies": {
|
||||
"through": "~2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pe-library": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
|
||||
@@ -8597,15 +8456,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/put": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/put/-/put-0.0.6.tgz",
|
||||
"integrity": "sha512-w0szIZ2NkqznMFqxYPRETCIi+q/S8UKis9F4yOl6/N9NDCZmbjZZT85aI4FgJf3vIPrzMPX60+odCLOaYxNWWw==",
|
||||
"license": "MIT/X11",
|
||||
"engines": {
|
||||
"node": ">=0.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
@@ -9467,18 +9317,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/split": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz",
|
||||
"integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"through": "2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
@@ -9510,16 +9348,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-combiner": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz",
|
||||
"integrity": "sha512-6yHMqgLYDzQDcAkL+tjJDC5nSNuNIx0vZtRZeiPh7Saef7VHX9H5Ijn9l2VIol2zaNYlYEX6KyuT/237A58qEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexer": "~0.1.1",
|
||||
"through": "~2.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -9788,12 +9616,6 @@
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-async-pool": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
|
||||
@@ -10380,15 +10202,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
|
||||
"integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
@@ -10432,28 +10245,6 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js/node_modules/xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||
|
||||
+13
-12
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jasonzhu1207/WeFlow"
|
||||
"url": "https://github.com/hicccc77/WeFlow"
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
@@ -24,7 +24,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"dbus-native": "^0.4.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -59,7 +58,7 @@
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -71,14 +70,16 @@
|
||||
"lodash": ">=4.17.21",
|
||||
"brace-expansion": ">=1.1.11",
|
||||
"picomatch": ">=2.3.1",
|
||||
"ajv": ">=8.18.0"
|
||||
"ajv": ">=8.18.0",
|
||||
"ajv-keywords@3>ajv": "^6.12.6",
|
||||
"@develar/schema-utils>ajv": "^6.12.6"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "Jasonzhu1207",
|
||||
"owner": "hicccc77",
|
||||
"repo": "WeFlow",
|
||||
"releaseType": "release"
|
||||
},
|
||||
@@ -97,7 +98,7 @@
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
@@ -106,19 +107,19 @@
|
||||
"icon": "public/icon.ico",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/msvcp140.dll",
|
||||
"from": "resources/runtime/win32/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
@@ -134,7 +135,7 @@
|
||||
"synopsis": "WeFlow for Linux",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/linux/install.sh",
|
||||
"from": "resources/installer/linux/install.sh",
|
||||
"to": "install.sh"
|
||||
}
|
||||
]
|
||||
@@ -189,7 +190,7 @@
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"overrides": {
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
Binary file not shown.
File renamed without changes.
File renamed without changes.
Executable → Regular
File renamed without changes.
File renamed without changes.
File renamed without changes.
Executable → Regular
File renamed without changes.
Executable → Regular
File renamed without changes.
Executable → Regular
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Executable → Regular
BIN
Binary file not shown.
Executable → Regular
File renamed without changes.
Binary file not shown.
File renamed without changes.
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+15
-65
@@ -107,44 +107,6 @@ function App() {
|
||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null)
|
||||
|
||||
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkWaylandStatus = async () => {
|
||||
try {
|
||||
// 防止在非客户端环境报错,先检查 API 是否存在
|
||||
if (!window.electronAPI?.app?.checkWayland) return
|
||||
|
||||
// 通过 configService 检查是否已经弹过窗
|
||||
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
|
||||
|
||||
if (!hasWarned) {
|
||||
const isWayland = await window.electronAPI.app.checkWayland()
|
||||
if (isWayland) {
|
||||
setShowWaylandWarning(true)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查 Wayland 状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在协议同意之后并且已经进入主应用流程才检查
|
||||
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
|
||||
checkWaylandStatus()
|
||||
}
|
||||
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
|
||||
|
||||
const handleDismissWaylandWarning = async () => {
|
||||
try {
|
||||
// 记录到本地配置中,下次不再提示
|
||||
await window.electronAPI.config.set('waylandWarningShown', true)
|
||||
} catch (e) {
|
||||
console.error('保存 Wayland 提示状态失败:', e)
|
||||
}
|
||||
setShowWaylandWarning(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== '/settings') {
|
||||
settingsBackgroundRef.current = location
|
||||
@@ -339,6 +301,21 @@ function App() {
|
||||
}
|
||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||
|
||||
// 监听通知点击导航事件
|
||||
useEffect(() => {
|
||||
if (isNotificationWindow) return
|
||||
|
||||
const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => {
|
||||
if (!sessionId) return
|
||||
// 导航到聊天页面,通过URL参数让ChatPage接收sessionId
|
||||
navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true })
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [navigate, isNotificationWindow])
|
||||
|
||||
// 解锁后显示暂存的更新弹窗
|
||||
useEffect(() => {
|
||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||
@@ -670,33 +647,6 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*{showWaylandWarning && (*/}
|
||||
{/* <div className="agreement-overlay">*/}
|
||||
{/* <div className="agreement-modal">*/}
|
||||
{/* <div className="agreement-header">*/}
|
||||
{/* <Shield size={32} />*/}
|
||||
{/* <h2>环境兼容性提示 (Wayland)</h2>*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="agreement-content">*/}
|
||||
{/* <div className="agreement-text">*/}
|
||||
{/* <p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>*/}
|
||||
{/* <p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>*/}
|
||||
{/* <p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>*/}
|
||||
{/* <br />*/}
|
||||
{/* <p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>*/}
|
||||
{/* <p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>*/}
|
||||
{/* <p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="agreement-footer">*/}
|
||||
{/* <div className="agreement-actions">*/}
|
||||
{/* <button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/* 更新提示对话框 */}
|
||||
<UpdateDialog
|
||||
open={showUpdateDialog}
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
|
||||
format?: string
|
||||
avatars?: boolean
|
||||
dateRange?: ExportDateRangeSelection
|
||||
fileNamingMode?: configService.ExportFileNamingMode
|
||||
media?: configService.ExportDefaultMediaConfig
|
||||
voiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
|
||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||
] as const
|
||||
|
||||
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
|
||||
{ value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' },
|
||||
{ value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' }
|
||||
]
|
||||
|
||||
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
||||
|
||||
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
||||
@@ -56,12 +62,15 @@ export function ExportDefaultsSettingsForm({
|
||||
layout = 'stacked'
|
||||
}: ExportDefaultsSettingsFormProps) {
|
||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
|
||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||
images: true,
|
||||
videos: true,
|
||||
@@ -76,10 +85,11 @@ export function ExportDefaultsSettingsForm({
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultAvatars(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultFileNamingMode(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
@@ -91,6 +101,7 @@ export function ExportDefaultsSettingsForm({
|
||||
setExportDefaultFormat(savedFormat || 'excel')
|
||||
setExportDefaultAvatars(savedAvatars ?? true)
|
||||
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
||||
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||
setExportDefaultMedia(savedMedia ?? {
|
||||
images: true,
|
||||
videos: true,
|
||||
@@ -114,15 +125,19 @@ export function ExportDefaultsSettingsForm({
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showExportExcelColumnsSelect])
|
||||
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
|
||||
|
||||
const notify = (text: string, success = true) => {
|
||||
onNotify?.(text, success)
|
||||
@@ -224,6 +239,7 @@ export function ExportDefaultsSettingsForm({
|
||||
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
setIsExportDateRangeDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
@@ -247,6 +263,50 @@ export function ExportDefaultsSettingsForm({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>导出文件命名方式</label>
|
||||
<span className="form-hint">控制导出文件名是否包含时间范围</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportFileNamingModeLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportFileNamingModeSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFileNamingModeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFileNamingMode(option.value)
|
||||
await configService.setExportDefaultFileNamingMode(option.value)
|
||||
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||
notify('已更新导出文件命名方式', true)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
<span className="option-desc">{option.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="form-copy">
|
||||
<label>Excel 列显示</label>
|
||||
@@ -259,6 +319,7 @@ export function ExportDefaultsSettingsForm({
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFileNamingModeSelect(false)
|
||||
setIsExportDateRangeDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1965,6 +1965,10 @@
|
||||
color: var(--on-primary);
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
}
|
||||
|
||||
.bubble-body {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
// 对方发送的消息 - 左侧白色
|
||||
@@ -1974,6 +1978,10 @@
|
||||
color: var(--text-primary);
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
}
|
||||
|
||||
.bubble-body {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&.system {
|
||||
@@ -2038,6 +2046,12 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
// 让文字气泡按内容收缩,不被群昵称行宽度牵连
|
||||
.message-bubble:not(.system) .bubble-content {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// 表情包消息
|
||||
.message-bubble.emoji {
|
||||
.bubble-content {
|
||||
|
||||
+15
-1
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
@@ -1142,6 +1142,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType])
|
||||
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
@@ -5350,6 +5351,19 @@ function ChatPage(props: ChatPageProps) {
|
||||
selectSessionById
|
||||
])
|
||||
|
||||
// 监听URL参数中的sessionId,用于通知点击导航
|
||||
useEffect(() => {
|
||||
if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理
|
||||
const params = new URLSearchParams(location.search)
|
||||
const urlSessionId = params.get('sessionId')
|
||||
if (!urlSessionId) return
|
||||
if (!isConnected || isConnecting) return
|
||||
if (currentSessionId === urlSessionId) return
|
||||
selectSessionById(urlSessionId)
|
||||
// 选中后清除URL参数,避免影响后续用户手动切换会话
|
||||
navigate('/chat', { replace: true })
|
||||
}, [standaloneSessionWindow, location.search, isConnected, isConnecting, currentSessionId, selectSessionById, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!standaloneSessionWindow || !normalizedInitialSessionId) return
|
||||
if (!isConnected || isConnecting) {
|
||||
|
||||
@@ -1621,6 +1621,7 @@ function ExportPage() {
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
|
||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||
const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||
images: true,
|
||||
videos: true,
|
||||
@@ -2270,7 +2271,7 @@ function ExportPage() {
|
||||
setIsBaseConfigLoading(true)
|
||||
let isReady = true
|
||||
try {
|
||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
|
||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
|
||||
configService.getExportPath(),
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultAvatars(),
|
||||
@@ -2287,6 +2288,7 @@ function ExportPage() {
|
||||
configService.getExportWriteLayout(),
|
||||
configService.getExportSessionNamePrefixEnabled(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultFileNamingMode(),
|
||||
ensureExportCacheScope()
|
||||
])
|
||||
|
||||
@@ -2318,6 +2320,7 @@ function ExportPage() {
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
|
||||
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
|
||||
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
|
||||
setTimeRangeSelection(resolvedDefaultDateRange)
|
||||
@@ -4397,6 +4400,7 @@ function ExportPage() {
|
||||
displayNamePreference: options.displayNamePreference,
|
||||
exportConcurrency: options.exportConcurrency,
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
fileNamingMode: exportDefaultFileNamingMode,
|
||||
sessionLayout,
|
||||
sessionNameWithTypePrefix,
|
||||
dateRange: options.useAllTime
|
||||
@@ -7089,6 +7093,9 @@ function ExportPage() {
|
||||
if (patch.dateRange) {
|
||||
setExportDefaultDateRangeSelection(patch.dateRange)
|
||||
}
|
||||
if (patch.fileNamingMode) {
|
||||
setExportDefaultFileNamingMode(patch.fileNamingMode)
|
||||
}
|
||||
if (patch.media) {
|
||||
const mediaPatch = patch.media
|
||||
setExportDefaultMedia(mediaPatch)
|
||||
|
||||
+86
-21
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
|
||||
import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react'
|
||||
import { VirtuosoGrid } from 'react-virtuoso'
|
||||
import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor'
|
||||
import './ResourcesPage.scss'
|
||||
|
||||
type MediaTab = 'image' | 'video'
|
||||
@@ -35,10 +36,14 @@ type DialogState = {
|
||||
onConfirm?: (() => void) | null
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 120
|
||||
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 18
|
||||
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 36
|
||||
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 4
|
||||
const PAGE_SIZE = 96
|
||||
const MAX_IMAGE_CACHE_RESOLVE_PER_TICK = 12
|
||||
const MAX_IMAGE_CACHE_PRELOAD_PER_TICK = 24
|
||||
const MAX_VIDEO_POSTER_RESOLVE_PER_TICK = 3
|
||||
const INITIAL_IMAGE_PRELOAD_END = 48
|
||||
const INITIAL_IMAGE_RESOLVE_END = 12
|
||||
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
|
||||
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
|
||||
|
||||
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
|
||||
const { className = '', ...rest } = props
|
||||
@@ -409,7 +414,13 @@ function ResourcesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electronAPI.chat.connect()
|
||||
if (reset) {
|
||||
const connectResult = await window.electronAPI.chat.connect()
|
||||
if (!connectResult.success) {
|
||||
setError(connectResult.error || '连接数据库失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
const requestOffset = reset ? 0 : nextOffset
|
||||
const streamResult = await window.electronAPI.chat.getMediaStream({
|
||||
sessionId: selectedContact === 'all' ? undefined : selectedContact,
|
||||
@@ -524,7 +535,6 @@ function ResourcesPage() {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
await window.electronAPI.chat.connect()
|
||||
const sessionResult = await window.electronAPI.chat.getSessions()
|
||||
if (!cancelled && sessionResult.success && Array.isArray(sessionResult.sessions)) {
|
||||
const initialNameMap: Record<string, string> = {}
|
||||
@@ -674,7 +684,10 @@ function ResourcesPage() {
|
||||
resolvingImageCacheBatchRef.current = true
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.image.resolveCacheBatch(payloads, { disableUpdateCheck: true })
|
||||
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: false
|
||||
})
|
||||
const rows = Array.isArray(result?.rows) ? result.rows : []
|
||||
const pathPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
@@ -741,7 +754,10 @@ function ResourcesPage() {
|
||||
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
|
||||
}
|
||||
if (payloads.length === 0) return
|
||||
void window.electronAPI.image.preload(payloads, { allowDecrypt: false })
|
||||
void window.electronAPI.image.preload(payloads, {
|
||||
allowDecrypt: false,
|
||||
allowCacheIndex: false
|
||||
})
|
||||
}, [displayItems])
|
||||
|
||||
const resolveItemVideoMd5 = useCallback(async (item: MediaStreamItem): Promise<string> => {
|
||||
@@ -813,14 +829,18 @@ function ResourcesPage() {
|
||||
if (!pending) return
|
||||
pendingRangeRef.current = null
|
||||
if (tab === 'image') {
|
||||
preloadImageCacheRange(pending.start - 8, pending.end + 32)
|
||||
resolveImageCacheRange(pending.start - 2, pending.end + 8)
|
||||
preloadImageCacheRange(pending.start - 4, pending.end + 20)
|
||||
resolveImageCacheRange(pending.start - 1, pending.end + 6)
|
||||
return
|
||||
}
|
||||
resolvePosterRange(pending.start, pending.end)
|
||||
}, [preloadImageCacheRange, resolveImageCacheRange, resolvePosterRange, tab])
|
||||
|
||||
const scheduleRangeResolve = useCallback((start: number, end: number) => {
|
||||
const previous = pendingRangeRef.current
|
||||
if (previous && start >= previous.start && end <= previous.end) {
|
||||
return
|
||||
}
|
||||
pendingRangeRef.current = { start, end }
|
||||
if (rangeTimerRef.current !== null) {
|
||||
window.clearTimeout(rangeTimerRef.current)
|
||||
@@ -832,8 +852,8 @@ function ResourcesPage() {
|
||||
useEffect(() => {
|
||||
if (displayItems.length === 0) return
|
||||
if (tab === 'image') {
|
||||
preloadImageCacheRange(0, Math.min(displayItems.length - 1, 80))
|
||||
resolveImageCacheRange(0, Math.min(displayItems.length - 1, 20))
|
||||
preloadImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_PRELOAD_END))
|
||||
resolveImageCacheRange(0, Math.min(displayItems.length - 1, INITIAL_IMAGE_RESOLVE_END))
|
||||
return
|
||||
}
|
||||
resolvePosterRange(0, Math.min(displayItems.length - 1, 12))
|
||||
@@ -1057,25 +1077,61 @@ function ResourcesPage() {
|
||||
|
||||
setBatchBusy(true)
|
||||
let success = 0
|
||||
let failed = 0
|
||||
const previewPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage: 'other',
|
||||
title: '资源页图片批量解密',
|
||||
detail: `正在解密图片(0/${imageItems.length})`,
|
||||
progressText: `0 / ${imageItems.length}`,
|
||||
cancelable: false
|
||||
})
|
||||
try {
|
||||
let completed = 0
|
||||
const progressStep = Math.max(1, Math.floor(imageItems.length / TASK_PROGRESS_UPDATE_MAX_STEPS))
|
||||
let lastProgressBucket = 0
|
||||
let lastProgressUpdateAt = Date.now()
|
||||
const updateTaskProgress = (force: boolean = false) => {
|
||||
const now = Date.now()
|
||||
const bucket = Math.floor(completed / progressStep)
|
||||
const crossedBucket = bucket !== lastProgressBucket
|
||||
const intervalReached = now - lastProgressUpdateAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS
|
||||
if (!force && !crossedBucket && !intervalReached) return
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: `正在解密图片(${completed}/${imageItems.length})`,
|
||||
progressText: `${completed} / ${imageItems.length}`
|
||||
})
|
||||
lastProgressBucket = bucket
|
||||
lastProgressUpdateAt = now
|
||||
}
|
||||
for (const item of imageItems) {
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
if (!item.imageMd5 && !item.imageDatName) {
|
||||
failed += 1
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
continue
|
||||
}
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
})
|
||||
if (!result?.success) continue
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
if (!result?.success) {
|
||||
failed += 1
|
||||
} else {
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
}
|
||||
}
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
}
|
||||
updateTaskProgress(true)
|
||||
|
||||
if (Object.keys(previewPatch).length > 0) {
|
||||
setPreviewPathMap((prev) => ({ ...prev, ...previewPatch }))
|
||||
@@ -1083,8 +1139,17 @@ function ResourcesPage() {
|
||||
if (Object.keys(updatePatch).length > 0) {
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||
}
|
||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`)
|
||||
showAlert(`批量解密完成:成功 ${success},失败 ${imageItems.length - success}`, '批量解密完成')
|
||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
|
||||
showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
|
||||
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
|
||||
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
|
||||
progressText: `成功 ${success} / 失败 ${failed}`
|
||||
})
|
||||
} catch (e) {
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
detail: `资源页图片批量解密失败:${String(e)}`
|
||||
})
|
||||
showAlert(`批量解密失败:${String(e)}`, '批量解密失败')
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
|
||||
@@ -238,23 +238,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('')
|
||||
const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('')
|
||||
|
||||
const [isWayland, setIsWayland] = useState(false)
|
||||
useEffect(() => {
|
||||
const checkWaylandStatus = async () => {
|
||||
if (window.electronAPI?.app?.checkWayland) {
|
||||
try {
|
||||
const wayland = await window.electronAPI.app.checkWayland()
|
||||
setIsWayland(wayland)
|
||||
} catch (e) {
|
||||
console.error('检查 Wayland 状态失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkWaylandStatus()
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
// 检查 Hello 可用性
|
||||
useEffect(() => {
|
||||
setHelloAvailable(isWindows)
|
||||
@@ -1474,13 +1457,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
{
|
||||
value: 'quote-top' as const,
|
||||
label: '引用在上',
|
||||
description: '更接近当前 WeFlow 风格',
|
||||
successMessage: '已切换为引用在上样式'
|
||||
},
|
||||
{
|
||||
value: 'quote-bottom' as const,
|
||||
label: '正文在上',
|
||||
description: '更接近微信 / 密语风格',
|
||||
successMessage: '已切换为正文在上样式'
|
||||
}
|
||||
].map(option => {
|
||||
@@ -1530,7 +1511,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="quote-layout-card-footer">
|
||||
<div className="quote-layout-card-title-group">
|
||||
<span className="quote-layout-card-title">{option.label}</span>
|
||||
<span className="quote-layout-card-desc">{option.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1672,7 +1652,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>新消息通知</label>
|
||||
<span className="form-hint">开启后,收���新消息时将显示桌面弹窗通知</span>
|
||||
<span className="form-hint">开启后,收到新消息时将显示桌面弹窗通知</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="notification-enabled-toggle">
|
||||
@@ -1696,11 +1676,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="form-group">
|
||||
<label>通知显示位置</label>
|
||||
<span className="form-hint">选择通知弹窗在屏幕上的显示位置</span>
|
||||
{isWayland && (
|
||||
<span className="form-hint" style={{ color: '#ff4d4f', marginTop: '4px', display: 'block' }}>
|
||||
⚠️ 注意:Wayland 环境下该配置可能无效!
|
||||
</span>
|
||||
)}
|
||||
<div className="custom-select">
|
||||
<div
|
||||
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
|
||||
@@ -3676,7 +3651,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
||||
<div className="updates-hero-main">
|
||||
<span className="updates-chip">当前版本</span>
|
||||
<h2>{appVersion || '...'}</h2>
|
||||
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更���'}</p>
|
||||
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更新'}</p>
|
||||
</div>
|
||||
<div className="updates-hero-action">
|
||||
{updateInfo?.hasUpdate ? (
|
||||
|
||||
@@ -31,6 +31,7 @@ const steps = [
|
||||
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
|
||||
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
|
||||
]
|
||||
type SetupStepId = typeof steps[number]['id']
|
||||
|
||||
interface WelcomePageProps {
|
||||
standalone?: boolean
|
||||
@@ -438,6 +439,48 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const jumpToStep = (stepId: SetupStepId) => {
|
||||
const targetIndex = steps.findIndex(step => step.id === stepId)
|
||||
if (targetIndex >= 0) setStepIndex(targetIndex)
|
||||
}
|
||||
|
||||
const validateDbStepBeforeNext = async (): Promise<string | null> => {
|
||||
if (!dbPath) return '数据库目录步骤未完成:请先选择数据库目录'
|
||||
if (dbPathValidationError) return `数据库目录步骤配置有误:${dbPathValidationError}`
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
if (!Array.isArray(wxids) || wxids.length === 0) {
|
||||
return '数据库目录步骤配置有误:当前目录下未找到可用账号数据(缺少 db_storage),请重新选择微信数据目录'
|
||||
}
|
||||
} catch (e) {
|
||||
return `数据库目录步骤配置有误:目录读取失败,请确认该路径可访问(${String(e)})`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const findConfigIssueBeforeConnect = async (): Promise<{ stepId: SetupStepId; message: string } | null> => {
|
||||
const dbIssue = await validateDbStepBeforeNext()
|
||||
if (dbIssue) return { stepId: 'db', message: dbIssue }
|
||||
|
||||
let scannedWxids: Array<{ wxid: string }> = []
|
||||
try {
|
||||
scannedWxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
} catch {
|
||||
scannedWxids = []
|
||||
}
|
||||
|
||||
if (!wxid) {
|
||||
return { stepId: 'key', message: '解密密钥步骤未完成:请先选择微信账号 (wxid)' }
|
||||
}
|
||||
if (!scannedWxids.some(item => item.wxid === wxid)) {
|
||||
return { stepId: 'key', message: `解密密钥步骤配置有误:微信账号「${wxid}」不在当前数据库目录中,请重新选择账号` }
|
||||
}
|
||||
if (!decryptKey || decryptKey.length !== 64) {
|
||||
return { stepId: 'key', message: '解密密钥步骤未完成:请填写 64 位解密密钥' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const canGoNext = () => {
|
||||
if (currentStep.id === 'intro') return true
|
||||
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
|
||||
@@ -453,7 +496,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
if (currentStep.id === 'db') {
|
||||
const dbStepIssue = await validateDbStepBeforeNext()
|
||||
if (dbStepIssue) {
|
||||
setError(dbStepIssue)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!canGoNext()) {
|
||||
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
|
||||
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
|
||||
@@ -473,9 +524,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!dbPath) { setError('请先选择数据库目录'); return }
|
||||
if (!wxid) { setError('请填写微信ID'); return }
|
||||
if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return }
|
||||
const configIssue = await findConfigIssueBeforeConnect()
|
||||
if (configIssue) {
|
||||
setError(configIssue.message)
|
||||
jumpToStep(configIssue.stepId)
|
||||
return
|
||||
}
|
||||
|
||||
setIsConnecting(true)
|
||||
setError('')
|
||||
@@ -484,7 +538,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
try {
|
||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
||||
if (!result.success) {
|
||||
setError(result.error || 'WCDB 连接失败')
|
||||
const errorMessage = result.error || 'WCDB 连接失败'
|
||||
if (errorMessage.includes('-3001')) {
|
||||
const fallbackIssue = await findConfigIssueBeforeConnect()
|
||||
if (fallbackIssue) {
|
||||
setError(fallbackIssue.message)
|
||||
jumpToStep(fallbackIssue.stepId)
|
||||
} else {
|
||||
setError(`数据库目录步骤配置有误:${errorMessage}`)
|
||||
jumpToStep('db')
|
||||
}
|
||||
} else {
|
||||
setError(errorMessage)
|
||||
}
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
||||
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
|
||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||
EXPORT_DEFAULT_FILE_NAMING_MODE: 'exportDefaultFileNamingMode',
|
||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||
@@ -114,6 +115,8 @@ export interface ExportDefaultMediaConfig {
|
||||
files: boolean
|
||||
}
|
||||
|
||||
export type ExportFileNamingMode = 'classic' | 'date-range'
|
||||
|
||||
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||
export type QuoteLayout = 'quote-top' | 'quote-bottom'
|
||||
export type UpdateChannel = 'stable' | 'preview' | 'dev'
|
||||
@@ -434,6 +437,18 @@ export async function setExportDefaultDateRange(range: ExportDefaultDateRangeCon
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||
}
|
||||
|
||||
// 获取导出默认文件命名方式
|
||||
export async function getExportDefaultFileNamingMode(): Promise<ExportFileNamingMode | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE)
|
||||
if (value === 'classic' || value === 'date-range') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认文件命名方式
|
||||
export async function setExportDefaultFileNamingMode(mode: ExportFileNamingMode): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE, mode)
|
||||
}
|
||||
|
||||
// 获取导出默认媒体设置
|
||||
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
|
||||
|
||||
export interface BatchImageDecryptState {
|
||||
isBatchDecrypting: boolean
|
||||
@@ -8,8 +14,9 @@ export interface BatchImageDecryptState {
|
||||
result: { success: number; fail: number }
|
||||
startTime: number
|
||||
sessionName: string
|
||||
taskId: string | null
|
||||
|
||||
startDecrypt: (total: number, sessionName: string) => void
|
||||
startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishDecrypt: (success: number, fail: number) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
@@ -17,7 +24,26 @@ export interface BatchImageDecryptState {
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({
|
||||
const clampProgress = (current: number, total: number): { current: number; total: number } => {
|
||||
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
|
||||
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
|
||||
const normalizedCurrent = normalizedTotal > 0
|
||||
? Math.min(normalizedCurrentRaw, normalizedTotal)
|
||||
: normalizedCurrentRaw
|
||||
return { current: normalizedCurrent, total: normalizedTotal }
|
||||
}
|
||||
|
||||
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
|
||||
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
|
||||
|
||||
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
|
||||
|
||||
const calcProgressStep = (total: number): number => {
|
||||
if (total <= 0) return 1
|
||||
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
|
||||
}
|
||||
|
||||
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, get) => ({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
@@ -25,40 +51,127 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) =>
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: '',
|
||||
taskId: null,
|
||||
|
||||
startDecrypt: (total, sessionName) => set({
|
||||
isBatchDecrypting: true,
|
||||
progress: { current: 0, total },
|
||||
showToast: true,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: Date.now(),
|
||||
sessionName
|
||||
}),
|
||||
startDecrypt: (total, sessionName, sourcePage = 'chat') => {
|
||||
const previousTaskId = get().taskId
|
||||
if (previousTaskId) {
|
||||
taskProgressUpdateMeta.delete(previousTaskId)
|
||||
finishBackgroundTask(previousTaskId, 'canceled', {
|
||||
detail: '已被新的批量解密任务替换',
|
||||
progressText: '已替换'
|
||||
})
|
||||
}
|
||||
|
||||
updateProgress: (current, total) => set({
|
||||
progress: { current, total }
|
||||
}),
|
||||
const normalizedProgress = clampProgress(0, total)
|
||||
const normalizedSessionName = String(sessionName || '').trim()
|
||||
const title = normalizedSessionName
|
||||
? `图片批量解密(${normalizedSessionName})`
|
||||
: '图片批量解密'
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage,
|
||||
title,
|
||||
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
|
||||
cancelable: false
|
||||
})
|
||||
taskProgressUpdateMeta.set(taskId, {
|
||||
lastAt: Date.now(),
|
||||
lastBucket: 0,
|
||||
step: calcProgressStep(normalizedProgress.total)
|
||||
})
|
||||
|
||||
finishDecrypt: (success, fail) => set({
|
||||
isBatchDecrypting: false,
|
||||
showToast: false,
|
||||
showResultToast: true,
|
||||
result: { success, fail },
|
||||
startTime: 0
|
||||
}),
|
||||
set({
|
||||
isBatchDecrypting: true,
|
||||
progress: normalizedProgress,
|
||||
showToast: true,
|
||||
showResultToast: false,
|
||||
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 }),
|
||||
setShowResultToast: (show) => set({ showResultToast: show }),
|
||||
|
||||
reset: () => set({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: ''
|
||||
})
|
||||
}))
|
||||
reset: () => {
|
||||
const taskId = get().taskId
|
||||
if (taskId) {
|
||||
taskProgressUpdateMeta.delete(taskId)
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '批量解密任务已重置',
|
||||
progressText: '已停止'
|
||||
})
|
||||
}
|
||||
|
||||
set({
|
||||
isBatchDecrypting: false,
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResultToast: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
startTime: 0,
|
||||
sessionName: '',
|
||||
taskId: null
|
||||
})
|
||||
}
|
||||
}))
|
||||
Vendored
+11
-4
@@ -69,7 +69,6 @@ export interface ElectronAPI {
|
||||
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
|
||||
onDownloadProgress: (callback: (progress: number) => void) => () => void
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
|
||||
checkWayland: () => Promise<boolean>
|
||||
}
|
||||
notification: {
|
||||
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
|
||||
@@ -78,6 +77,7 @@ export interface ElectronAPI {
|
||||
ready: () => void
|
||||
resize: (width: number, height: number) => void
|
||||
onShow: (callback: (event: any, data: any) => void) => () => void
|
||||
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
|
||||
}
|
||||
log: {
|
||||
getPath: () => Promise<string>
|
||||
@@ -403,10 +403,16 @@ export interface ElectronAPI {
|
||||
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; disableUpdateCheck?: boolean }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean }
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||
@@ -414,7 +420,7 @@ export interface ElectronAPI {
|
||||
}>
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { allowDecrypt?: boolean }
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => Promise<boolean>
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||
@@ -999,6 +1005,7 @@ export interface ExportOptions {
|
||||
exportVoiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
txtColumns?: string[]
|
||||
fileNamingMode?: 'classic' | 'date-range'
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
|
||||
Reference in new issue
Block a user