Merge branch 'hicccc77:main' into main

This commit is contained in:
Jason authored and GitHub committed 2026-04-17 22:36:42 +08:00
commit b8bcfa23be
43 files changed
+2966 -1732

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env bash
set -euo pipefail
retry_cmd() {
local attempts="$1"
local delay_seconds="$2"
shift 2
local i
local exit_code
for ((i = 1; i <= attempts; i++)); do
if "$@"; then
return 0
fi
exit_code=$?
if [ "$i" -lt "$attempts" ]; then
echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
echo "Retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Command failed after $attempts attempts: $*" >&2
return "$exit_code"
}
capture_cmd_with_retry() {
local result_var="$1"
local attempts="$2"
local delay_seconds="$3"
shift 3
local i
local output=""
local exit_code=1
for ((i = 1; i <= attempts; i++)); do
if output="$("$@" 2>/dev/null)"; then
printf -v "$result_var" "%s" "$output"
return 0
fi
exit_code=$?
if [ "$i" -lt "$attempts" ]; then
echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
echo "Retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Capture command failed after $attempts attempts: $*" >&2
return "$exit_code"
}
wait_for_release_id() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
local release_id
for ((i = 1; i <= attempts; i++)); do
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "$release_id"
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
settle_release_state() {
local repo="$1"
local release_id="$2"
local tag="$3"
local attempts="${4:-12}"
local delay_seconds="${5:-2}"
local endpoint="repos/$repo/releases/tags/$tag"
local i
local draft_state
local prerelease_state
for ((i = 1; i <= attempts; i++)); do
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Failed to settle release state for tag '$tag'." >&2
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
wait_for_release_absent() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
for ((i = 1; i <= attempts; i++)); do
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
if [ "$i" -lt "$attempts" ]; then
echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
continue
fi
return 0
done
echo "Release '$tag' still exists after waiting." >&2
gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true
return 1
}
wait_for_git_tag_absent() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
for ((i = 1; i <= attempts; i++)); do
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
if [ "$i" -lt "$attempts" ]; then
echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
continue
fi
return 0
done
echo "Git tag '$tag' still exists after waiting." >&2
gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true
return 1
}
recreate_fixed_prerelease() {
local repo="$1"
local tag="$2"
local target_branch="$3"
local release_title="$4"
local release_notes="$5"
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag
fi
wait_for_release_absent "$repo" "$tag" 12 2
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag"
fi
wait_for_git_tag_absent "$repo" "$tag" 12 2
local created="false"
local i
for ((i = 1; i <= 6; i++)); do
if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then
created="true"
break
fi
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2
created="true"
break
fi
if [ "$i" -lt 6 ]; then
echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2
sleep 3
fi
done
if [ "$created" != "true" ]; then
echo "Failed to create release '$tag'." >&2
return 1
fi
local release_id
release_id="$(wait_for_release_id "$repo" "$tag" 12 2)"
settle_release_state "$repo" "$release_id" "$tag" 12 2
}
upload_release_assets_with_retry() {
local repo="$1"
local tag="$2"
shift 2
if [ "$#" -eq 0 ]; then
echo "No release assets provided for upload." >&2
return 1
fi
wait_for_release_id "$repo" "$tag" 12 2 >/dev/null
retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber
}
+17 -48
View File
@@ -55,28 +55,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
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')"
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
source .github/scripts/release-utils.sh
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
dev-mac-arm64:
needs: prepare
@@ -93,7 +73,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -135,6 +114,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
@@ -143,7 +124,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-linux:
needs: prepare
@@ -160,7 +141,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -183,6 +163,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
@@ -191,7 +173,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-win-x64:
needs: prepare
@@ -208,7 +190,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -231,6 +212,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
@@ -239,7 +222,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-win-arm64:
needs: prepare
@@ -256,7 +239,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -279,6 +261,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
@@ -287,7 +271,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
update-dev-release-notes:
needs:
@@ -386,22 +370,7 @@ jobs:
}
update_release_notes
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
settled="false"
for i in 1 2 3 4 5; do
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
settled="true"
break
fi
sleep 2
done
if [ "$settled" != "true" ]; then
echo "Failed to settle release state after notes update:"
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
exit 1
fi
source .github/scripts/release-utils.sh
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
+17 -48
View File
@@ -81,28 +81,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
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')"
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
source .github/scripts/release-utils.sh
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
preview-mac-arm64:
needs: prepare
@@ -120,7 +100,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -164,6 +143,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
@@ -172,7 +153,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-linux:
needs: prepare
@@ -190,7 +171,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -216,6 +196,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
@@ -224,7 +206,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-win-x64:
needs: prepare
@@ -242,7 +224,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -268,6 +249,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
@@ -276,7 +259,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-win-arm64:
needs: prepare
@@ -294,7 +277,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -320,6 +302,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
@@ -328,7 +312,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
update-preview-release-notes:
needs:
@@ -429,22 +413,7 @@ jobs:
}
update_release_notes
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
settled="false"
for i in 1 2 3 4 5; do
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
settled="true"
break
fi
sleep 2
done
if [ "$settled" != "true" ]; then
echo "Failed to settle release state after notes update:"
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
exit 1
fi
source .github/scripts/release-utils.sh
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
+27 -14
View File
@@ -27,7 +27,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -59,15 +58,21 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE" 2>/dev/null || continue
if ! retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE"; then
echo "Skip $YML_FILE because download failed after retries."
continue
fi
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
fi
gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
done
release-linux:
@@ -84,7 +89,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -117,13 +121,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
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
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" || true
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
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
fi
release:
@@ -140,7 +147,6 @@ jobs:
with:
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm install
@@ -168,13 +174,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
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
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" || true
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
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
fi
release-windows-arm64:
@@ -191,7 +200,6 @@ jobs:
with:
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm install
@@ -219,13 +227,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
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
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" || true
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
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
fi
update-release-notes:
@@ -243,12 +254,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG="$GITHUB_REF_NAME"
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
capture_cmd_with_retry ASSETS_JSON 8 3 gh release view "$TAG" --repo "$REPO" --json assets
pick_asset() {
local pattern="$1"
@@ -299,7 +312,7 @@ jobs:
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
EOF
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
deploy-aur:
runs-on: ubuntu-latest
+1 -1
View File
@@ -75,4 +75,4 @@ pnpm-lock.yaml
wechat-research-site
.codex
weflow-web-offical
Insight
/Wedecrypt
+130 -28
View File
@@ -372,6 +372,7 @@ if (process.platform === 'darwin') {
let mainWindowReady = false
let shouldShowMain = true
let isAppQuitting = false
let shutdownPromise: Promise<void> | null = null
let tray: Tray | null = null
let isClosePromptVisible = false
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
@@ -2663,15 +2664,30 @@ function registerIpcHandlers() {
// 私聊克隆
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
ipcMain.handle('image:decrypt', async (_, payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
force?: boolean
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) => {
return imageDecryptService.decryptImage(payload)
})
ipcMain.handle('image:resolveCache', async (_, payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) => {
return imageDecryptService.resolveCachedImage(payload)
})
@@ -2679,17 +2695,84 @@ function registerIpcHandlers() {
'image:resolveCacheBatch',
async (
_,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
payloads: Array<{
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
suppressEvents?: boolean
}>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => {
const list = Array.isArray(payloads) ? payloads : []
const rows = await Promise.all(list.map(async (payload) => {
return imageDecryptService.resolveCachedImage({
...payload,
disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false
})
}))
if (list.length === 0) return { success: true, rows: [] }
const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 10)
const maxConcurrent = Number.isFinite(maxConcurrentRaw)
? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 48))
: 10
const workerCount = Math.min(maxConcurrent, list.length)
const rows: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> = new Array(list.length)
let cursor = 0
const dedupe = new Map<string, Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>>()
const makeDedupeKey = (payload: typeof list[number]): string => {
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
const createTime = Number(payload.createTime || 0) || 0
const preferFilePath = payload.preferFilePath ?? options?.preferFilePath === true
const hardlinkOnly = payload.hardlinkOnly ?? options?.hardlinkOnly === true
const allowCacheIndex = options?.allowCacheIndex !== false
const disableUpdateCheck = options?.disableUpdateCheck === true
const suppressEvents = payload.suppressEvents ?? options?.suppressEvents === true
return [
sessionId,
imageMd5,
imageDatName,
String(createTime),
preferFilePath ? 'pf1' : 'pf0',
hardlinkOnly ? 'hl1' : 'hl0',
allowCacheIndex ? 'ci1' : 'ci0',
disableUpdateCheck ? 'du1' : 'du0',
suppressEvents ? 'se1' : 'se0'
].join('|')
}
const resolveOne = (payload: typeof list[number]) => imageDecryptService.resolveCachedImage({
...payload,
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
disableUpdateCheck: options?.disableUpdateCheck === true,
allowCacheIndex: options?.allowCacheIndex !== false,
suppressEvents: payload.suppressEvents ?? options?.suppressEvents === true
})
const worker = async () => {
while (true) {
const index = cursor
cursor += 1
if (index >= list.length) return
const payload = list[index]
const key = makeDedupeKey(payload)
const existing = dedupe.get(key)
if (existing) {
rows[index] = await existing
continue
}
const task = resolveOne(payload).catch((error) => ({
success: false,
error: String(error)
}))
dedupe.set(key, task)
rows[index] = await task
}
}
await Promise.all(Array.from({ length: workerCount }, () => worker()))
return { success: true, rows }
}
)
@@ -2697,12 +2780,19 @@ function registerIpcHandlers() {
'image:preload',
async (
_,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => {
imagePreloadService.enqueue(payloads || [], options)
return true
})
ipcMain.handle(
'image:preloadHardlinkMd5s',
async (_, md5List?: string[]) => {
await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : [])
return true
}
)
// Windows Hello
ipcMain.handle('auth:hello', async (event, message?: string) => {
@@ -3780,23 +3870,35 @@ app.whenReady().then(async () => {
})
})
app.on('before-quit', async () => {
isAppQuitting = true
// 销毁 tray 图标
if (tray) { try { tray.destroy() } catch {} tray = null }
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
destroyNotificationWindow()
insightService.stop()
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout')
app.exit(0)
}, 5000)
forceExitTimer.unref()
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
try { await httpService.stop() } catch {}
// 终止 wcdb Worker 线程,避免线程阻止进程退出
try { await wcdbService.shutdown() } catch {}
const shutdownAppServices = async (): Promise<void> => {
if (shutdownPromise) return shutdownPromise
shutdownPromise = (async () => {
isAppQuitting = true
// 销毁 tray 图标
if (tray) { try { tray.destroy() } catch {} tray = null }
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
destroyNotificationWindow()
messagePushService.stop()
insightService.stop()
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout')
app.exit(0)
}, 5000)
forceExitTimer.unref()
try { await cloudControlService.stop() } catch {}
// 停止 chatService(内部会关闭 cursor 与 DB),避免退出阶段仍触发监控回调
try { chatService.close() } catch {}
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
try { await httpService.stop() } catch {}
// 终止 wcdb Worker 线程,避免线程阻止进程退出
try { await wcdbService.shutdown() } catch {}
})()
return shutdownPromise
}
app.on('before-quit', () => {
void shutdownAppServices()
})
app.on('window-all-closed', () => {
+21 -4
View File
@@ -286,24 +286,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 图片解密
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
decrypt: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
force?: boolean
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:resolveCache', payload),
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options),
preloadHardlinkMd5s: (md5List: string[]) =>
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener)
+19 -10
View File
@@ -486,7 +486,7 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : null
}
private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string {
private toCodeOnlyMessage(rawMessage?: string | null, fallbackCode = -3999): string {
const code = this.extractErrorCode(rawMessage) ?? fallbackCode
return `错误码: ${code}`
}
@@ -7105,13 +7105,23 @@ class ChatService {
return { success: false, error: '未找到消息' }
}
const msg = msgResult.message
const rawImageInfo = msg.rawContent ? this.parseImageInfo(msg.rawContent) : {}
const imageMd5 = msg.imageMd5 || rawImageInfo.md5
const imageDatName = msg.imageDatName
// 2. 使用 imageDecryptService 解密图片
if (!imageMd5 && !imageDatName) {
return { success: false, error: '图片缺少 md5/datName,无法定位原文件' }
}
// 2. 使用 imageDecryptService 解密图片(仅使用真实图片标识)
const result = await this.imageDecryptService.decryptImage({
sessionId,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName || String(msg.localId),
force: false
imageMd5,
imageDatName,
createTime: msg.createTime,
force: false,
preferFilePath: true,
hardlinkOnly: true
})
if (!result.success || !result.localPath) {
@@ -8358,7 +8368,6 @@ class ChatService {
if (normalized.length === 0) return []
// 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。
// 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。
const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900)
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512
? Math.floor(maxBytesRaw)
@@ -9325,7 +9334,7 @@ class ChatService {
latest_ts: this.toSafeInt(item?.latest_ts, 0),
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0)
})).filter((item) => item.session_id)
})).filter((item: MyFootprintPrivateSession) => item.session_id)
const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({
session_id: String(item?.session_id || '').trim(),
@@ -9344,7 +9353,7 @@ class ChatService {
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
displayName: String(item?.displayName || '').trim() || undefined,
avatarUrl: String(item?.avatarUrl || '').trim() || undefined
})).filter((item) => item.session_id && item.start_ts > 0)
})).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0)
const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({
session_id: String(item?.session_id || '').trim(),
@@ -9353,13 +9362,13 @@ class ChatService {
sender_username: String(item?.sender_username || '').trim(),
message_content: String(item?.message_content || ''),
source: String(item?.source || '')
})).filter((item) => item.session_id)
})).filter((item: MyFootprintMentionItem) => item.session_id)
const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({
session_id: String(item?.session_id || '').trim(),
count: this.toSafeInt(item?.count, 0),
latest_ts: this.toSafeInt(item?.latest_ts, 0)
})).filter((item) => item.session_id)
})).filter((item: MyFootprintMentionGroup) => item.session_id)
const diagnostics: MyFootprintDiagnostics = {
truncated: Boolean(diagnosticsRaw.truncated),
+8 -2
View File
@@ -218,7 +218,7 @@ class CloudControlService {
this.pages.add(pageName)
}
stop() {
async stop(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
@@ -230,7 +230,13 @@ class CloudControlService {
this.circuitOpenedAt = 0
this.nextDelayOverrideMs = null
this.initialized = false
wcdbService.cloudStop()
if (wcdbService.isReady()) {
try {
await wcdbService.cloudStop()
} catch {
// 忽略停止失败,避免阻塞主进程退出
}
}
}
async getLogs() {
+9 -2
View File
@@ -2,6 +2,7 @@
import { app, safeStorage } from 'electron'
import crypto from 'crypto'
import Store from 'electron-store'
import { expandHomePath } from '../utils/pathUtils'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
@@ -42,7 +43,6 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[]
// 安全相关
@@ -168,7 +168,6 @@ export class ConfigService {
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
@@ -299,6 +298,10 @@ export class ConfigService {
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
}
if (key === 'dbPath' && typeof raw === 'string') {
return expandHomePath(raw) as ConfigSchema[K]
}
return raw
}
@@ -306,6 +309,10 @@ export class ConfigService {
let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
if (key === 'dbPath' && typeof value === 'string') {
toStore = expandHomePath(value) as ConfigSchema[K]
}
if (ENCRYPTED_BOOL_KEYS.has(key)) {
const boolValue = value === true || value === 'true'
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
+18 -14
View File
@@ -2,6 +2,7 @@ import { join, basename } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { homedir } from 'os'
import { createDecipheriv } from 'crypto'
import { expandHomePath } from '../utils/pathUtils'
export interface WxidInfo {
wxid: string
@@ -139,13 +140,14 @@ export class DbPathService {
* 查找账号目录(包含 db_storage 或图片目录)
*/
findAccountDirs(rootPath: string): string[] {
const resolvedRootPath = expandHomePath(rootPath)
const accounts: string[] = []
try {
const entries = readdirSync(rootPath)
const entries = readdirSync(resolvedRootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
const entryPath = join(resolvedRootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
@@ -216,13 +218,14 @@ export class DbPathService {
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
const wxids: WxidInfo[] = []
try {
if (existsSync(rootPath)) {
const entries = readdirSync(rootPath)
if (existsSync(resolvedRootPath)) {
const entries = readdirSync(resolvedRootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
const entryPath = join(resolvedRootPath, entry)
let stat: ReturnType<typeof statSync>
try { stat = statSync(entryPath) } catch { continue }
if (!stat.isDirectory()) continue
@@ -235,9 +238,9 @@ export class DbPathService {
if (wxids.length === 0) {
const rootName = basename(rootPath)
const rootName = basename(resolvedRootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
const rootStat = statSync(rootPath)
const rootStat = statSync(resolvedRootPath)
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
}
}
@@ -248,7 +251,7 @@ export class DbPathService {
return a.wxid.localeCompare(b.wxid)
});
const globalInfo = this.parseGlobalConfig(rootPath);
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
@@ -266,19 +269,20 @@ export class DbPathService {
* 扫描 wxid 列表
*/
scanWxids(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
const wxids: WxidInfo[] = []
try {
if (this.isAccountDir(rootPath)) {
const wxid = basename(rootPath)
const modifiedTime = this.getAccountModifiedTime(rootPath)
if (this.isAccountDir(resolvedRootPath)) {
const wxid = basename(resolvedRootPath)
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
return [{ wxid, modifiedTime }]
}
const accounts = this.findAccountDirs(rootPath)
const accounts = this.findAccountDirs(resolvedRootPath)
for (const account of accounts) {
const fullPath = join(rootPath, account)
const fullPath = join(resolvedRootPath, account)
const modifiedTime = this.getAccountModifiedTime(fullPath)
wxids.push({ wxid: account, modifiedTime })
}
@@ -289,7 +293,7 @@ export class DbPathService {
return a.wxid.localeCompare(b.wxid)
});
const globalInfo = this.parseGlobalConfig(rootPath);
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
+21 -29
View File
@@ -108,7 +108,6 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
}
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -443,8 +442,8 @@ class ExportService {
let lastSessionId = ''
let lastCollected = 0
let lastExported = 0
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
const MIN_PROGRESS_EMIT_INTERVAL_MS = 400
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200
const commit = (progress: ExportProgress) => {
onProgress(progress)
@@ -1092,8 +1091,7 @@ class ExportService {
private getImageMissingRunCacheKey(
sessionId: string,
imageMd5?: unknown,
imageDatName?: unknown,
imageDeepSearchOnMiss = true
imageDatName?: unknown
): string | null {
const normalizedSessionId = String(sessionId || '').trim()
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
@@ -1105,8 +1103,7 @@ class ExportService {
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
? normalizedImageDatName
: ''
const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink'
return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
}
private normalizeEmojiMd5(value: unknown): string | undefined {
@@ -3583,7 +3580,6 @@ class ExportService {
exportVoiceAsText?: boolean
includeVideoPoster?: boolean
includeVoiceWithTranscript?: boolean
imageDeepSearchOnMiss?: boolean
dirCache?: Set<string>
}
): Promise<MediaExportItem | null> {
@@ -3596,8 +3592,7 @@ class ExportService {
sessionId,
mediaRootDir,
mediaRelativePrefix,
options.dirCache,
options.imageDeepSearchOnMiss !== false
options.dirCache
)
if (result) {
}
@@ -3654,8 +3649,7 @@ class ExportService {
sessionId: string,
mediaRootDir: string,
mediaRelativePrefix: string,
dirCache?: Set<string>,
imageDeepSearchOnMiss = true
dirCache?: Set<string>
): Promise<MediaExportItem | null> {
try {
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
@@ -3675,8 +3669,7 @@ class ExportService {
const missingRunCacheKey = this.getImageMissingRunCacheKey(
sessionId,
imageMd5,
imageDatName,
imageDeepSearchOnMiss
imageDatName
)
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
return null
@@ -3686,26 +3679,31 @@ class ExportService {
sessionId,
imageMd5,
imageDatName,
createTime: msg.createTime,
force: true, // 导出优先高清,失败再回退缩略图
preferFilePath: true,
hardlinkOnly: !imageDeepSearchOnMiss
hardlinkOnly: true,
disableUpdateCheck: true,
allowCacheIndex: !imageMd5,
suppressEvents: true
})
if (!result.success || !result.localPath) {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
if (!imageDeepSearchOnMiss) {
console.log(`[Export] 未命中 hardlink(已关闭缺图深度搜索)→ 将显示 [图片] 占位符`)
if (missingRunCacheKey) {
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
}
return null
if (result.failureKind === 'decrypt_failed') {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
} else {
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
}
// 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId,
imageMd5,
imageDatName,
preferFilePath: true
createTime: msg.createTime,
preferFilePath: true,
disableUpdateCheck: true,
allowCacheIndex: !imageMd5,
suppressEvents: true
})
if (thumbResult.success && thumbResult.localPath) {
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
@@ -5302,7 +5300,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -5813,7 +5810,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -6685,7 +6681,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -7436,7 +7431,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -7816,7 +7810,6 @@ class ExportService {
maxFileSizeMb: options.maxFileSizeMb,
exportVoiceAsText: options.exportVoiceAsText,
includeVideoPoster: options.format === 'html',
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
@@ -8240,7 +8233,6 @@ class ExportService {
includeVideoPoster: options.format === 'html',
includeVoiceWithTranscript: true,
exportVideos: options.exportVideos,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
dirCache: mediaDirCache
})
mediaCache.set(mediaKey, mediaItem)
+68 -17
View File
@@ -1208,6 +1208,30 @@ class HttpService {
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
this.ensureDir(sessionDir)
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
if (options.exportImages) {
const imageMd5Set = new Set<string>()
for (const msg of messages) {
if (msg.localType !== 3) continue
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
if (imageMd5) {
imageMd5Set.add(imageMd5)
continue
}
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
imageMd5Set.add(imageDatName)
}
}
if (imageMd5Set.size > 0) {
try {
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
} catch {
// ignore preload failures
}
}
}
for (const msg of messages) {
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
if (exported) {
@@ -1230,27 +1254,54 @@ class HttpService {
sessionId: talker,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName,
force: true
createTime: msg.createTime,
force: true,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
if (result.success && result.localPath) {
let imagePath = result.localPath
let imagePath = result.success ? result.localPath : undefined
if (!imagePath) {
try {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: talker,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName,
createTime: msg.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
if (cached.success && cached.localPath) {
imagePath = cached.localPath
}
} catch {
// ignore resolve failures
}
}
if (imagePath) {
if (imagePath.startsWith('data:')) {
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
if (base64Match) {
const imageBuffer = Buffer.from(base64Match[1], 'base64')
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
const fileName = `${fileBase}${ext}`
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, imageBuffer)
}
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
if (!base64Match) return null
const imageBuffer = Buffer.from(base64Match[1], 'base64')
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
const fileName = `${fileBase}${ext}`
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, imageBuffer)
}
} else if (fs.existsSync(imagePath)) {
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
}
if (fs.existsSync(imagePath)) {
const imageBuffer = fs.readFileSync(imagePath)
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
+866 -1099
View File
@@ -3,10 +3,11 @@ import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
import { writeFile, rm, readdir } from 'fs/promises'
import { homedir, tmpdir } from 'os'
import crypto from 'crypto'
import { Worker } from 'worker_threads'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
@@ -34,7 +35,7 @@ function getStaticFfmpegPath(): string | null {
}
// 方法3: 打包后的路径
if (app.isPackaged) {
if (app?.isPackaged) {
const resourcesPath = process.resourcesPath
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(packedPath)) {
@@ -52,6 +53,7 @@ type DecryptResult = {
success: boolean
localPath?: string
error?: string
failureKind?: 'not_found' | 'decrypt_failed'
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
}
@@ -61,24 +63,41 @@ type CachedImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}
type DecryptImagePayload = CachedImagePayload & {
force?: boolean
hardlinkOnly?: boolean
}
export class ImageDecryptService {
private configService = new ConfigService()
private resolvedCache = new Map<string, string>()
private pending = new Map<string, Promise<DecryptResult>>()
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private cacheIndexed = false
private cacheIndexing: Promise<void> | null = null
private updateFlags = new Map<string, boolean>()
private nativeLogged = false
private datNameScanMissAt = new Map<string, number>()
private readonly datNameScanMissTtlMs = 1200
private readonly accountDirCache = new Map<string, string>()
private cacheRootPath: string | null = null
private readonly ensuredDirs = new Set<string>()
private shouldEmitImageEvents(payload?: { suppressEvents?: boolean }): boolean {
if (payload?.suppressEvents === true) return false
// 导出 worker 场景不需要向渲染层广播逐条图片事件,避免事件风暴拖慢主界面。
if (process.env.WEFLOW_WORKER === '1') return false
return true
}
private shouldCheckImageUpdate(payload?: { disableUpdateCheck?: boolean; suppressEvents?: boolean }): boolean {
if (payload?.disableUpdateCheck === true) return false
return this.shouldEmitImageEvents(payload)
}
private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
@@ -106,7 +125,7 @@ export class ImageDecryptService {
private writeLog(line: string): void {
try {
const logDir = join(app.getPath('userData'), 'logs')
const logDir = join(this.getUserDataPath(), 'logs')
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true })
}
@@ -117,28 +136,29 @@ export class ImageDecryptService {
}
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
if (payload.allowCacheIndex !== false) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
return { success: false, error: '缺少图片标识' }
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
}
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
const isThumb = this.isThumbnailPath(cached)
const upgraded = this.isThumbnailPath(cached)
? await this.tryPromoteThumbnailCache(payload, key, cached)
: null
const finalPath = upgraded || cached
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(finalPath)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
if (!payload.disableUpdateCheck) {
this.triggerUpdateCheck(payload, key, cached)
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, key, finalPath)
}
} else {
this.updateFlags.delete(key)
}
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
if (cached && !this.isImageFile(cached)) {
@@ -146,36 +166,53 @@ export class ImageDecryptService {
}
}
for (const key of cacheKeys) {
const existing = this.findCachedOutput(key, false, payload.sessionId)
if (existing) {
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
if (!payload.disableUpdateCheck) {
this.triggerUpdateCheck(payload, key, existing)
const accountDir = this.resolveCurrentAccountDir()
if (accountDir) {
const datPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
}
)
if (datPath) {
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false)
if (existing) {
const upgraded = this.isThumbnailPath(existing)
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing)
: null
const finalPath = upgraded || existing
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath)
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(finalPath)
const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false
if (isThumb) {
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, finalPath)
}
} else {
this.updateFlags.delete(cacheKey)
}
} else {
this.updateFlags.delete(key)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
}
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到缓存图片' }
return { success: false, error: '未找到缓存图片', failureKind: 'not_found' }
}
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
if (!payload.hardlinkOnly) {
await this.ensureCacheIndexed()
}
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
return { success: false, error: '缺少图片标识' }
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
}
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
@@ -195,25 +232,17 @@ export class ImageDecryptService {
}
}
if (!payload.hardlinkOnly) {
for (const key of cacheKeys) {
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
if (!existingHd || this.isThumbnailPath(existingHd)) continue
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath }
}
}
}
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
const upgraded = this.isThumbnailPath(cached)
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
: null
const finalPath = upgraded || cached
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath }
}
@@ -260,13 +289,13 @@ export class ImageDecryptService {
for (const row of result.rows) {
const md5 = String(row?.md5 || '').trim().toLowerCase()
if (!md5) continue
const fullPath = String(row?.data?.full_path || '').trim()
if (!fullPath || !existsSync(fullPath)) continue
this.cacheDatPath(accountDir, md5, fullPath)
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
if (fileName) {
this.cacheDatPath(accountDir, fileName, fullPath)
}
const fullPath = String(row?.data?.full_path || '').trim()
if (!fileName || !fullPath) continue
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
if (!selectedPath || !existsSync(selectedPath)) continue
this.cacheDatPath(accountDir, md5, selectedPath)
this.cacheDatPath(accountDir, fileName, selectedPath)
}
} catch {
// ignore preload failures
@@ -285,14 +314,14 @@ export class ImageDecryptService {
if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
return { success: false, error: '未配置账号或数据库路径' }
return { success: false, error: '未配置账号或数据库路径', failureKind: 'not_found' }
}
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, wxid })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
return { success: false, error: '未找到账号目录' }
return { success: false, error: '未找到账号目录', failureKind: 'not_found' }
}
let datPath: string | null = null
@@ -307,10 +336,12 @@ export class ImageDecryptService {
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{
allowThumbnail: false,
skipResolvedCache: true,
hardlinkOnly: payload.hardlinkOnly === true
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
}
)
if (!datPath) {
@@ -319,10 +350,12 @@ export class ImageDecryptService {
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{
allowThumbnail: true,
skipResolvedCache: true,
hardlinkOnly: payload.hardlinkOnly === true
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
}
)
fallbackToThumbnail = Boolean(datPath)
@@ -339,10 +372,12 @@ export class ImageDecryptService {
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{
allowThumbnail: true,
skipResolvedCache: false,
hardlinkOnly: payload.hardlinkOnly === true
hardlinkOnly: payload.hardlinkOnly === true,
allowDatNameScanFallback: payload.allowCacheIndex !== false
}
)
}
@@ -351,9 +386,9 @@ export class ImageDecryptService {
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件')
if (usedHdAttempt) {
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' }
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试', failureKind: 'not_found' }
}
return { success: false, error: '未找到图片文件' }
return { success: false, error: '未找到图片文件', failureKind: 'not_found' }
}
this.logInfo('找到DAT文件', { datPath })
@@ -368,21 +403,18 @@ export class ImageDecryptService {
return { success: true, localPath, isThumb }
}
// 查找已缓存的解密文件(hardlink-only 模式下跳过全缓存目录扫描)
if (!payload.hardlinkOnly) {
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb }
}
const preferHdCache = Boolean(payload.force && !fallbackToThumbnail)
const existingFast = this.findCachedOutputByDatPath(datPath, payload.sessionId, preferHdCache)
if (existingFast) {
this.logInfo('找到已解密文件(按DAT快速命中)', { existing: existingFast, isHd: this.isHdPath(existingFast) })
const isHd = this.isHdPath(existingFast)
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingFast)
const localPath = this.resolveLocalPathForPayload(existingFast, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existingFast)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingFast, payload.preferFilePath))
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
return { success: true, localPath, isThumb }
}
}
@@ -403,50 +435,55 @@ export class ImageDecryptService {
}
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
return { success: false, error: '未配置图片解密密钥' }
return { success: false, error: '未配置图片解密密钥', failureKind: 'not_found' }
}
const aesKeyRaw = imageKeys.aesKey
const aesKey = this.resolveAesKey(aesKeyRaw)
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
const aesKeyForNative = aesKeyText || undefined
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
if (!nativeResult) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用')
return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置', failureKind: 'not_found' }
}
let decrypted: Buffer = nativeResult.data
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
// 统一走原有 wxgf/ffmpeg 流程,确保行为与历史版本一致
const wxgfResult = await this.unwrapWxgf(decrypted)
decrypted = wxgfResult.data
let ext = this.detectImageExtension(decrypted)
const detectedExt = this.detectImageExtension(decrypted)
// 如果是 wxgf 格式且没检测到扩展名
if (wxgfResult.isWxgf && !ext) {
ext = '.hevc'
// 如果解密产物无法识别为图片,归类为“解密失败”。
if (!detectedExt) {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '解密后不是有效图片')
return {
success: false,
error: '解密后不是有效图片',
failureKind: 'decrypt_failed',
isThumb: this.isThumbnailPath(datPath)
}
}
const finalExt = ext || '.jpg'
const finalExt = detectedExt
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
if (finalExt === '.hevc') {
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败')
return {
success: false,
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
isThumb: this.isThumbnailPath(datPath)
}
}
const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
} else {
this.triggerUpdateCheck(payload, cacheKey, outputPath)
if (this.shouldCheckImageUpdate(payload)) {
this.triggerUpdateCheck(payload, cacheKey, outputPath)
}
}
const localPath = payload.preferFilePath
? outputPath
@@ -458,18 +495,30 @@ export class ImageDecryptService {
} catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e))
return { success: false, error: String(e) }
return { success: false, error: String(e), failureKind: 'not_found' }
}
}
private resolveAccountDir(dbPath: string, wxid: string): string | null {
const cleanedWxid = this.cleanAccountDirName(wxid)
const normalized = dbPath.replace(/[\\/]+$/, '')
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
const cached = this.accountDirCache.get(cacheKey)
if (cached && existsSync(cached)) return cached
if (cached && !existsSync(cached)) {
this.accountDirCache.delete(cacheKey)
}
const direct = join(normalized, cleanedWxid)
if (existsSync(direct)) return direct
if (existsSync(direct)) {
this.accountDirCache.set(cacheKey, direct)
return direct
}
if (this.isAccountDir(normalized)) return normalized
if (this.isAccountDir(normalized)) {
this.accountDirCache.set(cacheKey, normalized)
return normalized
}
try {
const entries = readdirSync(normalized)
@@ -479,7 +528,10 @@ export class ImageDecryptService {
if (!this.isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
if (this.isAccountDir(entryPath)) return entryPath
if (this.isAccountDir(entryPath)) {
this.accountDirCache.set(cacheKey, entryPath)
return entryPath
}
}
}
} catch { }
@@ -487,6 +539,13 @@ export class ImageDecryptService {
return null
}
private resolveCurrentAccountDir(): string | null {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) return null
return this.resolveAccountDir(dbPath, wxid)
}
/**
* 获取解密后的缓存目录(用于查找 hardlink.db)
*/
@@ -549,206 +608,119 @@ export class ImageDecryptService {
imageMd5?: string,
imageDatName?: string,
sessionId?: string,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
createTime?: number,
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean }
): Promise<string | null> {
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
const hardlinkOnly = options?.hardlinkOnly ?? false
const allowDatNameScanFallback = options?.allowDatNameScanFallback ?? true
this.logInfo('[ImageDecrypt] resolveDatPath', {
imageMd5,
imageDatName,
createTime,
allowThumbnail,
skipResolvedCache,
hardlinkOnly
hardlinkOnly,
allowDatNameScanFallback
})
if (!skipResolvedCache) {
if (imageMd5) {
const cached = this.resolvedCache.get(imageMd5)
if (cached && existsSync(cached)) {
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
this.cacheDatPath(accountDir, imageMd5, preferred)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred)
return preferred
}
}
if (imageDatName) {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) {
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
this.cacheDatPath(accountDir, imageDatName, preferred)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred)
return preferred
}
}
}
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (!hardlinkOnly && allowThumbnail && imageMd5) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
if (res) return res
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (datNameRes) return datNameRes
}
}
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (res) return res
}
// 优先通过 hardlink.db 查询
if (imageMd5) {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
if (hardlinkPath) {
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath })
this.cacheDatPath(accountDir, imageMd5, preferredPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath)
return preferredPath
}
// hardlink 找到的是缩略图,但要求高清图
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
// 没找到高清图,返回 null(不进行全局搜索)
const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
const fallbackDatName = String(imageDatName || imageMd5 || '').trim().toLowerCase() || undefined
if (lookupMd5s.length === 0) {
if (!allowDatNameScanFallback) {
this.logInfo('[ImageDecrypt] resolveDatPath skip datName scan (no hardlink md5)', {
imageMd5,
imageDatName,
sessionId,
createTime
})
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (fallbackPath) {
const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
this.cacheDatPath(accountDir, imageDatName, preferredPath)
this.cacheDatPath(accountDir, imageMd5, preferredPath)
return preferredPath
}
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
this.cacheDatPath(accountDir, imageMd5, hdPath)
return hdPath
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
if (packedDatFallback) {
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
const normalizedFileName = basename(packedDatFallback).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
this.logInfo('[ImageDecrypt] datName fallback hit (no hardlink md5)', {
imageMd5,
imageDatName,
selectedPath: packedDatFallback
})
return packedDatFallback
}
}
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (hardlinkPath) {
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath })
this.cacheDatPath(accountDir, imageDatName, preferredPath)
return preferredPath
}
// hardlink 找到的是缩略图,但要求高清图
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
if (hardlinkOnly) {
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
this.logInfo('[ImageDecrypt] resolveDatPath miss (no hardlink md5)', { imageMd5, imageDatName })
return null
}
const searchNames = Array.from(
new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
)
if (searchNames.length === 0) return null
if (!skipResolvedCache) {
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 cacheCandidates = Array.from(new Set([
...lookupMd5s,
String(imageMd5 || '').trim().toLowerCase(),
String(imageDatName || '').trim().toLowerCase()
].filter(Boolean)))
for (const cacheKey of cacheCandidates) {
const scopedKey = `${accountDir}|${cacheKey}`
const cached = this.resolvedCache.get(scopedKey)
if (!cached) continue
if (!existsSync(cached)) continue
if (!allowThumbnail && this.isThumbnailPath(cached)) continue
return cached
}
}
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
}
for (const lookupMd5 of lookupMd5s) {
this.logInfo('[ImageDecrypt] hardlink lookup', { lookupMd5, sessionId, hardlinkOnly })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, lookupMd5, sessionId)
if (!hardlinkPath) continue
if (!allowThumbnail && this.isThumbnailPath(hardlinkPath)) continue
this.cacheDatPath(accountDir, lookupMd5, hardlinkPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
const normalizedFileName = basename(hardlinkPath).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath)
return hardlinkPath
}
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
}
}
if (!allowDatNameScanFallback) {
this.logInfo('[ImageDecrypt] resolveDatPath skip datName fallback after hardlink miss', {
imageMd5,
imageDatName,
sessionId,
createTime,
lookupMd5s
})
return null
}
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
if (packedDatFallback) {
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
const normalizedFileName = basename(packedDatFallback).toLowerCase()
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
this.logInfo('[ImageDecrypt] datName fallback hit (hardlink miss)', {
imageMd5,
imageDatName,
lookupMd5s,
selectedPath: packedDatFallback
})
return packedDatFallback
}
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink + datName fallback)', {
imageMd5,
imageDatName,
lookupMd5s
})
return null
}
private async resolveThumbnailDatPath(
accountDir: string,
imageMd5?: string,
imageDatName?: string,
sessionId?: string
): Promise<string | null> {
if (imageMd5) {
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath
}
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath
}
if (!imageDatName) return null
return this.searchDatFile(accountDir, imageDatName, true, true)
}
private async checkHasUpdate(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
cacheKey: string,
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number },
_cacheKey: string,
cachedPath: string
): Promise<boolean> {
if (!cachedPath || !existsSync(cachedPath)) return false
@@ -760,40 +732,98 @@ export class ImageDecryptService {
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) return false
const quickDir = this.getCachedDatDir(accountDir, payload.imageDatName, payload.imageMd5)
if (quickDir) {
const baseName = payload.imageDatName || payload.imageMd5 || cacheKey
const candidate = this.findNonThumbnailVariantInDir(quickDir, baseName)
if (candidate) {
return true
}
}
const thumbPath = await this.resolveThumbnailDatPath(
const hdPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId
payload.sessionId,
payload.createTime,
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
)
if (thumbPath) {
const baseName = payload.imageDatName || payload.imageMd5 || cacheKey
const candidate = this.findNonThumbnailVariantInDir(dirname(thumbPath), baseName)
if (candidate) {
return true
}
const searchHit = await this.searchDatFileInDir(dirname(thumbPath), baseName, false)
if (searchHit && this.isNonThumbnailVariantDat(searchHit)) {
return true
}
return Boolean(hdPath)
}
private async tryPromoteThumbnailCache(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean },
cacheKey: string,
cachedPath: string
): Promise<string | null> {
if (!cachedPath || !existsSync(cachedPath)) return null
if (!this.isImageFile(cachedPath)) return null
if (!this.isThumbnailPath(cachedPath)) return null
const accountDir = this.resolveCurrentAccountDir()
if (!accountDir) return null
const hdDatPath = await this.resolveDatPath(
accountDir,
payload.imageMd5,
payload.imageDatName,
payload.sessionId,
payload.createTime,
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
)
if (!hdDatPath) return null
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true)
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && !this.isThumbnailPath(existingHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.removeThumbnailCacheFile(cachedPath, existingHd)
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
cacheKey,
oldPath: cachedPath,
newPath: existingHd,
mode: 'existing'
})
return existingHd
}
return false
const upgraded = await this.decryptImage({
sessionId: payload.sessionId,
imageMd5: payload.imageMd5,
imageDatName: payload.imageDatName,
createTime: payload.createTime,
preferFilePath: true,
force: true,
hardlinkOnly: true,
disableUpdateCheck: true
})
if (!upgraded.success) return null
const cachedResult = this.resolvedCache.get(cacheKey)
const upgradedPath = (cachedResult && existsSync(cachedResult))
? cachedResult
: String(upgraded.localPath || '').trim()
if (!upgradedPath || !existsSync(upgradedPath)) return null
if (!this.isImageFile(upgradedPath) || this.isThumbnailPath(upgradedPath)) return null
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
this.removeThumbnailCacheFile(cachedPath, upgradedPath)
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
cacheKey,
oldPath: cachedPath,
newPath: upgradedPath,
mode: 're-decrypt'
})
return upgradedPath
}
private removeThumbnailCacheFile(oldPath: string, keepPath?: string): void {
if (!oldPath) return
if (keepPath && oldPath === keepPath) return
if (!existsSync(oldPath)) return
if (!this.isThumbnailPath(oldPath)) return
void rm(oldPath, { force: true }).catch(() => { })
}
private triggerUpdateCheck(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; disableUpdateCheck?: boolean; suppressEvents?: boolean },
cacheKey: string,
cachedPath: string
): void {
if (!this.shouldCheckImageUpdate(payload)) return
if (this.updateFlags.get(cacheKey)) return
void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => {
if (!hasUpdate) return
@@ -804,50 +834,409 @@ export class ImageDecryptService {
private resolveHardlinkDbPath(accountDir: string): string | null {
const wxid = this.configService.get('myWxid')
const cacheDir = wxid ? this.getDecryptedCacheDir(wxid) : null
const candidates = [
join(accountDir, 'db_storage', 'hardlink', 'hardlink.db'),
join(accountDir, 'hardlink.db'),
cacheDir ? join(cacheDir, 'hardlink.db') : null
].filter(Boolean) as string[]
this.logInfo('[ImageDecrypt] hardlink db probe', { accountDir, cacheDir, candidates })
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] {
const keys: string[] = []
const pushMd5 = (value?: string) => {
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) return
if (!this.looksLikeMd5(normalized)) return
if (!keys.includes(normalized)) keys.push(normalized)
}
this.logInfo('[ImageDecrypt] hardlink db missing', { accountDir, cacheDir, candidates })
pushMd5(imageMd5)
const datNameRaw = String(imageDatName || '').trim().toLowerCase()
if (!datNameRaw) return keys
pushMd5(datNameRaw)
const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw
pushMd5(datNameNoExt)
pushMd5(this.normalizeDatBase(datNameNoExt))
return keys
}
private resolveDatPathFromParsedDatName(
accountDir: string,
imageDatName?: string,
sessionId?: string,
createTime?: number,
allowThumbnail = true
): string | null {
const datNameRaw = String(imageDatName || '').trim().toLowerCase()
if (!datNameRaw) return null
const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw
const baseMd5 = this.normalizeDatBase(datNameNoExt)
if (!this.looksLikeMd5(baseMd5)) return null
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
const missKey = `${accountDir}|scan|${String(sessionId || '').trim()}|${monthKey}|${baseMd5}|${allowThumbnail ? 'all' : 'hd'}`
const lastMiss = this.datNameScanMissAt.get(missKey) || 0
if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) {
return null
}
const sessionMonthCandidates = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime)
if (sessionMonthCandidates.length > 0) {
const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5)
for (const candidatePath of orderedSessionMonth) {
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
this.datNameScanMissAt.delete(missKey)
this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail,
selectedPath: candidatePath
})
return candidatePath
}
}
const hasPreciseContext = Boolean(String(sessionId || '').trim() && monthKey)
if (hasPreciseContext) {
this.datNameScanMissAt.set(missKey, Date.now())
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail
})
return null
}
const candidates = this.collectDatCandidatesFromAccountDir(accountDir, baseMd5)
if (candidates.length === 0) {
this.datNameScanMissAt.set(missKey, Date.now())
this.logInfo('[ImageDecrypt] datName fallback scan miss', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail
})
return null
}
const ordered = this.sortDatCandidatePaths(candidates, baseMd5)
for (const candidatePath of ordered) {
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
this.datNameScanMissAt.delete(missKey)
this.logInfo('[ImageDecrypt] datName fallback selected', {
accountDir,
sessionId,
imageDatName: datNameRaw,
createTime,
monthKey,
baseMd5,
allowThumbnail,
selectedPath: candidatePath
})
return candidatePath
}
this.datNameScanMissAt.set(missKey, Date.now())
return null
}
private resolveYearMonthFromCreateTime(createTime?: number): string {
const raw = Number(createTime)
if (!Number.isFinite(raw) || raw <= 0) return ''
const ts = raw > 1e12 ? raw : raw * 1000
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
private collectDatCandidatesFromSessionMonth(
accountDir: string,
baseMd5: string,
sessionId?: string,
createTime?: number
): string[] {
const normalizedSessionId = String(sessionId || '').trim()
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
if (!normalizedSessionId || !monthKey) return []
const attachRoots = this.getAttachScanRoots(accountDir)
const cacheRoots = this.getMessageCacheScanRoots(accountDir)
const sessionDirs = this.getAttachSessionDirCandidates(normalizedSessionId)
const candidates = new Set<string>()
const budget = { remaining: 600 }
const targetDirs: Array<{ dir: string; depth: number }> = []
for (const root of attachRoots) {
for (const sessionDir of sessionDirs) {
targetDirs.push({ dir: join(root, sessionDir, monthKey), depth: 2 })
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Img'), depth: 1 })
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Image'), depth: 1 })
}
}
for (const root of cacheRoots) {
for (const sessionDir of sessionDirs) {
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir, 'Bubble'), depth: 1 })
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir), depth: 2 })
}
}
for (const target of targetDirs) {
if (budget.remaining <= 0) break
this.scanDatCandidatesUnderRoot(target.dir, baseMd5, target.depth, candidates, budget)
}
return Array.from(candidates)
}
private getAttachScanRoots(accountDir: string): string[] {
const roots: string[] = []
const push = (value: string) => {
const normalized = String(value || '').trim()
if (!normalized) return
if (!roots.includes(normalized)) roots.push(normalized)
}
push(join(accountDir, 'msg', 'attach'))
push(join(accountDir, 'attach'))
const parent = dirname(accountDir)
if (parent && parent !== accountDir) {
push(join(parent, 'msg', 'attach'))
push(join(parent, 'attach'))
}
return roots
}
private getMessageCacheScanRoots(accountDir: string): string[] {
const roots: string[] = []
const push = (value: string) => {
const normalized = String(value || '').trim()
if (!normalized) return
if (!roots.includes(normalized)) roots.push(normalized)
}
push(join(accountDir, 'cache'))
const parent = dirname(accountDir)
if (parent && parent !== accountDir) {
push(join(parent, 'cache'))
}
return roots
}
private getAttachSessionDirCandidates(sessionId: string): string[] {
const normalized = String(sessionId || '').trim()
if (!normalized) return []
const lower = normalized.toLowerCase()
const cleaned = this.cleanAccountDirName(normalized)
const inputs = Array.from(new Set([normalized, lower, cleaned, cleaned.toLowerCase()].filter(Boolean)))
const results: string[] = []
const push = (value: string) => {
if (!value) return
if (!results.includes(value)) results.push(value)
}
for (const item of inputs) {
push(item)
const md5 = crypto.createHash('md5').update(item).digest('hex').toLowerCase()
push(md5)
push(md5.slice(0, 16))
}
return results
}
private collectDatCandidatesFromAccountDir(accountDir: string, baseMd5: string): string[] {
const roots = this.getDatScanRoots(accountDir)
const candidates = new Set<string>()
const budget = { remaining: 1400 }
for (const item of roots) {
if (budget.remaining <= 0) break
this.scanDatCandidatesUnderRoot(item.root, baseMd5, item.maxDepth, candidates, budget)
}
if (candidates.size === 0 && budget.remaining <= 0) {
this.logInfo('[ImageDecrypt] datName fallback budget exhausted', {
accountDir,
baseMd5,
roots: roots.map((item) => item.root)
})
}
return Array.from(candidates)
}
private getDatScanRoots(accountDir: string): Array<{ root: string; maxDepth: number }> {
const roots: Array<{ root: string; maxDepth: number }> = []
const push = (root: string, maxDepth: number) => {
const normalized = String(root || '').trim()
if (!normalized) return
if (roots.some((item) => item.root === normalized)) return
roots.push({ root: normalized, maxDepth })
}
push(join(accountDir, 'attach'), 4)
push(join(accountDir, 'msg', 'attach'), 4)
push(join(accountDir, 'FileStorage', 'Image'), 3)
push(join(accountDir, 'FileStorage', 'Image2'), 3)
push(join(accountDir, 'FileStorage', 'MsgImg'), 3)
return roots
}
private scanDatCandidatesUnderRoot(
rootDir: string,
baseMd5: string,
maxDepth: number,
out: Set<string>,
budget: { remaining: number }
): void {
if (!rootDir || maxDepth < 0 || budget.remaining <= 0) return
if (!existsSync(rootDir) || !this.isDirectory(rootDir)) return
const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }]
while (stack.length > 0 && budget.remaining > 0) {
const current = stack.pop()
if (!current) break
budget.remaining -= 1
let entries: Array<{ name: string; isFile: () => boolean; isDirectory: () => boolean }>
try {
entries = readdirSync(current.dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isFile()) continue
const name = String(entry.name || '')
if (!this.isHardlinkCandidateName(name, baseMd5)) continue
const fullPath = join(current.dir, name)
if (existsSync(fullPath)) out.add(fullPath)
}
if (current.depth >= maxDepth) continue
for (const entry of entries) {
if (!entry.isDirectory()) continue
const name = String(entry.name || '')
if (!name || name === '.' || name === '..') continue
if (name.startsWith('.')) continue
stack.push({ dir: join(current.dir, name), depth: current.depth + 1 })
}
}
}
private sortDatCandidatePaths(paths: string[], baseMd5: string): string[] {
const list = Array.from(new Set(paths.filter(Boolean)))
list.sort((a, b) => {
const nameA = basename(a).toLowerCase()
const nameB = basename(b).toLowerCase()
const priorityA = this.getHardlinkCandidatePriority(nameA, baseMd5)
const priorityB = this.getHardlinkCandidatePriority(nameB, baseMd5)
if (priorityA !== priorityB) return priorityA - priorityB
let sizeA = 0
let sizeB = 0
try {
sizeA = statSync(a).size
} catch { }
try {
sizeB = statSync(b).size
} catch { }
if (sizeA !== sizeB) return sizeB - sizeA
let mtimeA = 0
let mtimeB = 0
try {
mtimeA = statSync(a).mtimeMs
} catch { }
try {
mtimeB = statSync(b).mtimeMs
} catch { }
if (mtimeA !== mtimeB) return mtimeB - mtimeA
return nameA.localeCompare(nameB)
})
return list
}
private isHardlinkCandidateName(fileName: string, baseMd5: string): boolean {
const lower = String(fileName || '').trim().toLowerCase()
if (!lower.endsWith('.dat')) return false
const base = lower.slice(0, -4)
if (base === baseMd5) return true
if (base.startsWith(`${baseMd5}_`) || base.startsWith(`${baseMd5}.`)) return true
if (base.length === baseMd5.length + 1 && base.startsWith(baseMd5)) return true
return this.normalizeDatBase(base) === baseMd5
}
private getHardlinkCandidatePriority(fileName: string, _baseMd5: string): number {
const lower = String(fileName || '').trim().toLowerCase()
if (!lower.endsWith('.dat')) return 999
const base = lower.slice(0, -4)
if (
base.endsWith('_h') ||
base.endsWith('.h') ||
base.endsWith('_hd') ||
base.endsWith('.hd')
) {
return 0
}
if (base.endsWith('_b') || base.endsWith('.b')) return 1
if (this.isThumbnailDat(lower)) return 3
return 2
}
private normalizeHardlinkDatPathByFileName(fullPath: string, fileName: string): string {
const normalizedPath = String(fullPath || '').trim()
const normalizedFileName = String(fileName || '').trim().toLowerCase()
if (!normalizedPath || !normalizedFileName) return normalizedPath
if (!normalizedFileName.endsWith('.dat')) return normalizedPath
const normalizedBase = this.normalizeDatBase(normalizedFileName.slice(0, -4))
if (!this.looksLikeMd5(normalizedBase)) return ''
// 最新策略:只要 hardlink 有记录,始终直接使用其记录路径(包括无后缀 DAT)。
return normalizedPath
}
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
try {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!this.looksLikeMd5(normalizedMd5)) return null
const ready = await this.ensureWcdbReady()
if (!ready) {
this.logInfo('[ImageDecrypt] hardlink db not ready')
return null
}
const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
const resolveResult = await wcdbService.resolveImageHardlink(normalizedMd5, accountDir)
if (!resolveResult.success || !resolveResult.data) return null
const fileName = String(resolveResult.data.file_name || '').trim()
const fullPath = String(resolveResult.data.full_path || '').trim()
if (!fileName) return null
if (!fileName || !fullPath) return null
const lowerFileName = String(fileName).toLowerCase()
if (lowerFileName.endsWith('.dat')) {
const baseLower = lowerFileName.slice(0, -4)
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
const normalizedBase = this.normalizeDatBase(lowerFileName.slice(0, -4))
if (!this.looksLikeMd5(normalizedBase)) {
this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName })
return null
}
}
if (fullPath && existsSync(fullPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
return fullPath
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
if (existsSync(selectedPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { md5: normalizedMd5, fileName, fullPath, selectedPath })
return selectedPath
}
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
this.logInfo('[ImageDecrypt] hardlink path miss', { md5: normalizedMd5, fileName, fullPath, selectedPath })
return null
} catch {
// ignore
@@ -879,182 +1268,6 @@ export class ImageDecryptService {
return value.replace(/'/g, "''")
}
private async searchDatFile(
accountDir: string,
datName: string,
allowThumbnail = true,
thumbOnly = false
): Promise<string | null> {
const key = `${accountDir}|${datName}`
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached)) {
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
}
const root = join(accountDir, 'msg', 'attach')
if (!existsSync(root)) return null
// 优化1:快速概率性查找
// 包含:1. 基于文件名的前缀猜测 (旧版)
// 2. 基于日期的最近月份扫描 (新版无索引时)
const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail)
if (fastHit) {
this.resolvedCache.set(key, fastHit)
return fastHit
}
// 优化2:兜底扫描 (异步非阻塞)
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
if (found) {
this.resolvedCache.set(key, found)
return found
}
return null
}
/**
* 基于文件名的哈希特征猜测可能的路径
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise<string | null> {
const { promises: fs } = require('fs')
const { join } = require('path')
try {
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
const lowerName = datName.toLowerCase()
const baseName = this.normalizeDatBase(lowerName)
const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail)
const candidates: string[] = []
if (/^[a-f0-9]{32}$/.test(baseName)) {
const dir1 = baseName.substring(0, 2)
const dir2 = baseName.substring(2, 4)
for (const targetName of targetNames) {
candidates.push(
join(root, dir1, dir2, targetName),
join(root, dir1, dir2, 'Img', targetName),
join(root, dir1, dir2, 'mg', targetName),
join(root, dir1, dir2, 'Image', targetName)
)
}
}
for (const path of candidates) {
try {
await fs.access(path)
return path
} catch { }
}
// --- 策略 B: 新版 Session 哈希路径猜测 ---
try {
const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
.map((e: any) => e.name)
if (sessionDirs.length === 0) return null
const now = new Date()
const months: string[] = []
// Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months".
for (let i = 0; i < 24; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
months.push(mStr)
}
const batchSize = 20
for (let i = 0; i < sessionDirs.length; i += batchSize) {
const batch = sessionDirs.slice(i, i + batchSize)
const tasks = batch.map(async (sessDir: string) => {
for (const month of months) {
const subDirs = ['Img', 'Image']
for (const sub of subDirs) {
const dirPath = join(root, sessDir, month, sub)
try { await fs.access(dirPath) } catch { continue }
for (const name of targetNames) {
const p = join(dirPath, name)
try { await fs.access(p); return p } catch { }
}
}
}
return null
})
const results = await Promise.all(tasks)
const hit = results.find(r => r !== null)
if (hit) return hit
}
} catch { }
} catch { }
return null
}
/**
* 在同一目录下查找高清图变体
* 优先 `_h`,再回退其他非缩略图变体
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath)
return this.findPreferredDatVariantInDir(dir, fileName, false)
} catch { }
return null
}
private async searchDatFileInDir(
dirPath: string,
datName: string,
allowThumbnail = true
): Promise<string | null> {
if (!existsSync(dirPath)) return null
return await this.walkForDatInWorker(dirPath, datName.toLowerCase(), 3, allowThumbnail, false)
}
private async walkForDatInWorker(
root: string,
datName: string,
maxDepth = 4,
allowThumbnail = true,
thumbOnly = false
): Promise<string | null> {
const workerPath = join(__dirname, 'imageSearchWorker.js')
return await new Promise((resolve) => {
const worker = new Worker(workerPath, {
workerData: { root, datName, maxDepth, allowThumbnail, thumbOnly }
})
const cleanup = () => {
worker.removeAllListeners()
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'done') {
cleanup()
void worker.terminate()
resolve(msg.path || null)
return
}
if (msg && msg.type === 'error') {
cleanup()
void worker.terminate()
resolve(null)
}
})
worker.on('error', () => {
cleanup()
void worker.terminate()
resolve(null)
})
})
}
private stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase()
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
@@ -1069,77 +1282,6 @@ export class ImageDecryptService {
return lower
}
private getDatVariantPriority(name: string): number {
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('_c') || baseLower.endsWith('.c')) return 400
if (this.isThumbnailDat(lower)) return 100
return 350
}
private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] {
if (!baseName) return []
const names = [
`${baseName}_h.dat`,
`${baseName}.h.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`
]
if (allowThumbnail) {
names.push(
`${baseName}_thumb.dat`,
`${baseName}.thumb.dat`,
`${baseName}_t.dat`,
`${baseName}.t.dat`
)
}
return Array.from(new Set(names))
}
private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null {
let entries: string[]
try {
entries = readdirSync(dirPath)
} catch {
return null
}
const target = this.normalizeDatBase(baseName.toLowerCase())
let bestPath: string | null = null
let bestScore = Number.NEGATIVE_INFINITY
for (const entry of entries) {
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
if (!allowThumbnail && this.isThumbnailDat(lower)) continue
const baseLower = lower.slice(0, -4)
if (this.normalizeDatBase(baseLower) !== target) continue
const score = this.getDatVariantPriority(lower)
if (score > bestScore) {
bestScore = score
bestPath = join(dirPath, entry)
}
}
return bestPath
}
private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string {
const lower = datPath.toLowerCase()
if (!lower.endsWith('.dat')) return datPath
const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail)
return preferred || datPath
}
private normalizeDatBase(name: string): string {
let base = name.toLowerCase()
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
@@ -1154,122 +1296,6 @@ export class ImageDecryptService {
}
}
private hasImageVariantSuffix(baseLower: string): boolean {
return this.stripDatVariantSuffix(baseLower) !== baseLower
}
private isLikelyImageDatBase(baseLower: string): boolean {
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower))
}
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
const allRoots = this.getAllCacheRoots()
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
// 遍历所有可能的缓存根路径
for (const root of allRoots) {
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
if (sessionId) {
const sessionDir = join(root, this.sanitizeDirName(sessionId))
if (existsSync(sessionDir)) {
try {
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
.map(d => d.name)
.sort()
.reverse() // 最新的日期优先
for (const dateDir of dateDirs) {
const imageDir = join(sessionDir, dateDir)
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch { }
}
}
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId)
try {
const sessionDirs = readdirSync(root, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
for (const session of sessionDirs) {
const sessionDir = join(root, session)
// 检查是否是日期目录结构
try {
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
.map(d => d.name)
for (const dateDir of subDirs) {
const imageDir = join(sessionDir, dateDir)
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch { }
}
} catch { }
// 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg
const oldImageDir = join(root, normalizedKey)
if (existsSync(oldImageDir)) {
const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
// 策略4: 最旧的平铺结构 Images/{file}.jpg
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}${ext}`)
if (existsSync(candidate)) return candidate
}
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}_t${ext}`)
if (existsSync(candidate)) return candidate
}
}
return null
}
private findCachedOutputInDir(
dirPath: string,
normalizedKey: string,
extensions: string[],
preferHd: boolean
): string | null {
// 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的)
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
try {
if (existsSync(hevcThumb)) {
require('fs').unlinkSync(hevcThumb)
}
if (existsSync(hevcHd)) {
require('fs').unlinkSync(hevcHd)
}
} catch { }
for (const ext of extensions) {
if (preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
if (existsSync(hdPath)) return hdPath
}
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
if (existsSync(thumbPath)) return thumbPath
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
if (!preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
if (existsSync(hdPath)) return hdPath
}
}
return null
}
private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string {
const name = basename(datPath)
const lower = name.toLowerCase()
@@ -1285,13 +1311,57 @@ export class ImageDecryptService {
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
const timeDir = this.resolveTimeDir(datPath)
const outputDir = join(this.getCacheRoot(), contactDir, timeDir)
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true })
}
this.ensureDir(outputDir)
return join(outputDir, `${normalizedBase}${suffix}${ext}`)
}
private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] {
const name = basename(datPath)
const lower = name.toLowerCase()
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
const normalizedBase = this.normalizeDatBase(base)
const suffixes = preferHd ? ['_hd', '_thumb'] : ['_thumb', '_hd']
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
const root = this.getCacheRoot()
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
const timeDir = this.resolveTimeDir(datPath)
const currentDir = join(root, contactDir, timeDir)
const legacyDir = join(root, normalizedBase)
const candidates: string[] = []
for (const suffix of suffixes) {
for (const ext of extensions) {
candidates.push(join(currentDir, `${normalizedBase}${suffix}${ext}`))
}
}
// 兼容旧目录结构
for (const suffix of suffixes) {
for (const ext of extensions) {
candidates.push(join(legacyDir, `${normalizedBase}${suffix}${ext}`))
}
}
// 兼容最旧平铺结构
for (const ext of extensions) {
candidates.push(join(root, `${normalizedBase}${ext}`))
candidates.push(join(root, `${normalizedBase}_t${ext}`))
candidates.push(join(root, `${normalizedBase}_hd${ext}`))
}
return candidates
}
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
return null
}
private cacheResolvedPaths(cacheKey: string, imageMd5: string | undefined, imageDatName: string | undefined, outputPath: string): void {
this.resolvedCache.set(cacheKey, outputPath)
if (imageMd5 && imageMd5 !== cacheKey) {
@@ -1334,43 +1404,37 @@ export class ImageDecryptService {
if (imageDatName) this.updateFlags.delete(imageDatName)
}
private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null {
const keys = [
imageDatName ? `${accountDir}|${imageDatName}` : null,
imageDatName ? `${accountDir}|${this.normalizeDatBase(imageDatName)}` : null,
imageMd5 ? `${accountDir}|${imageMd5}` : null
].filter(Boolean) as string[]
for (const key of keys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached)) return dirname(cached)
private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
try {
const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
if (typeof getter !== 'function') return []
const windows = getter()
if (!Array.isArray(windows)) return []
return windows.filter((win) => (
win &&
typeof win.isDestroyed === 'function' &&
win.webContents &&
typeof win.webContents.send === 'function'
))
} catch {
return []
}
return null
}
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
return this.findPreferredDatVariantInDir(dirPath, baseName, false)
}
private isNonThumbnailVariantDat(datPath: string): boolean {
const lower = basename(datPath).toLowerCase()
if (!lower.endsWith('.dat')) return false
if (this.isThumbnailDat(lower)) return false
const baseLower = lower.slice(0, -4)
return this.isLikelyImageDatBase(baseLower)
}
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string): void {
if (!this.shouldEmitImageEvents(payload)) return
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName }
for (const win of BrowserWindow.getAllWindows()) {
for (const win of this.getActiveWindowsSafely()) {
if (!win.isDestroyed()) {
win.webContents.send('image:updateAvailable', message)
}
}
}
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void {
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string, localPath: string): void {
if (!this.shouldEmitImageEvents(payload)) return
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath }
for (const win of BrowserWindow.getAllWindows()) {
for (const win of this.getActiveWindowsSafely()) {
if (!win.isDestroyed()) {
win.webContents.send('image:cacheResolved', message)
}
@@ -1378,13 +1442,14 @@ export class ImageDecryptService {
}
private emitDecryptProgress(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean },
cacheKey: string,
stage: DecryptProgressStage,
progress: number,
status: 'running' | 'done' | 'error',
message?: string
): void {
if (!this.shouldEmitImageEvents(payload)) return
const safeProgress = Math.max(0, Math.min(100, Math.floor(progress)))
const event = {
cacheKey,
@@ -1395,261 +1460,56 @@ export class ImageDecryptService {
status,
message: message || ''
}
for (const win of BrowserWindow.getAllWindows()) {
for (const win of this.getActiveWindowsSafely()) {
if (!win.isDestroyed()) {
win.webContents.send('image:decryptProgress', event)
}
}
}
private async ensureCacheIndexed(): Promise<void> {
if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing
this.cacheIndexing = (async () => {
// 扫描所有可能的缓存根目录
const allRoots = this.getAllCacheRoots()
this.logInfo('开始索引缓存', { roots: allRoots.length })
for (const root of allRoots) {
try {
this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构
} catch (e) {
this.logError('索引目录失败', e, { root })
}
}
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
this.cacheIndexed = true
this.cacheIndexing = null
})()
return this.cacheIndexing
}
/**
* 获取所有可能的缓存根路径(用于查找已缓存的图片)
* 包含当前路径、配置路径、旧版本路径
*/
private getAllCacheRoots(): string[] {
const roots: string[] = []
const configured = this.configService.get('cachePath')
const documentsPath = app.getPath('documents')
// 主要路径(当前使用的)
const mainRoot = this.getCacheRoot()
roots.push(mainRoot)
// 如果配置了自定义路径,也检查其下的 Images
if (configured) {
roots.push(join(configured, 'Images'))
roots.push(join(configured, 'images'))
}
// 默认路径
roots.push(join(documentsPath, 'WeFlow', 'Images'))
roots.push(join(documentsPath, 'WeFlow', 'images'))
// 兼容旧路径(如果有的话)
roots.push(join(documentsPath, 'WeFlowData', 'Images'))
// 去重并过滤存在的路径
const uniqueRoots = Array.from(new Set(roots))
const existingRoots = uniqueRoots.filter(r => existsSync(r))
return existingRoots
}
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
let entries: string[]
try {
entries = readdirSync(root)
} catch {
return
}
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
for (const entry of entries) {
const fullPath = join(root, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(fullPath)
} catch {
continue
}
if (stat.isDirectory()) {
if (depth < maxDepth) {
this.indexCacheDir(fullPath, maxDepth, depth + 1)
}
continue
}
if (!stat.isFile()) continue
const lower = entry.toLowerCase()
const ext = extensions.find((item) => lower.endsWith(item))
if (!ext) continue
const base = entry.slice(0, -ext.length)
this.addCacheIndex(base, fullPath)
const normalized = this.normalizeDatBase(base)
if (normalized && normalized !== base.toLowerCase()) {
this.addCacheIndex(normalized, fullPath)
}
}
}
private addCacheIndex(key: string, path: string): void {
const normalizedKey = key.toLowerCase()
const existing = this.resolvedCache.get(normalizedKey)
if (existing) {
const existingIsThumb = this.isThumbnailPath(existing)
const candidateIsThumb = this.isThumbnailPath(path)
if (!existingIsThumb && candidateIsThumb) return
}
this.resolvedCache.set(normalizedKey, path)
}
private getCacheRoot(): string {
const configured = this.configService.get('cachePath')
const root = configured
? join(configured, 'Images')
: join(app.getPath('documents'), 'WeFlow', 'Images')
if (!existsSync(root)) {
mkdirSync(root, { recursive: true })
let root = this.cacheRootPath
if (!root) {
const configured = this.configService.get('cachePath')
root = configured
? join(configured, 'Images')
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
this.cacheRootPath = root
}
this.ensureDir(root)
return root
}
private resolveAesKey(aesKeyRaw: string): Buffer | null {
const trimmed = aesKeyRaw?.trim() ?? ''
if (!trimmed) return null
return this.asciiKey16(trimmed)
private ensureDir(dirPath: string): void {
if (!dirPath) return
if (this.ensuredDirs.has(dirPath) && existsSync(dirPath)) return
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true })
}
this.ensuredDirs.add(dirPath)
}
private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise<Buffer> {
const version = this.getDatVersion(datPath)
if (version === 0) {
return this.decryptDatV3(datPath, xorKey)
}
if (version === 1) {
const key = this.asciiKey16(this.defaultV1AesKey)
return this.decryptDatV4(datPath, xorKey, key)
}
// version === 2
if (!aesKey || aesKey.length !== 16) {
throw new Error('请到设置配置图片解密密钥')
}
return this.decryptDatV4(datPath, xorKey, aesKey)
}
private getDatVersion(inputPath: string): number {
if (!existsSync(inputPath)) {
throw new Error('文件不存在')
}
const bytes = readFileSync(inputPath)
if (bytes.length < 6) {
return 0
}
const signature = bytes.subarray(0, 6)
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
return 1
}
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
return 2
}
return 0
}
private decryptDatV3(inputPath: string, xorKey: number): Buffer {
const data = readFileSync(inputPath)
const out = Buffer.alloc(data.length)
for (let i = 0; i < data.length; i += 1) {
out[i] = data[i] ^ xorKey
}
return out
}
private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer {
const bytes = readFileSync(inputPath)
if (bytes.length < 0x0f) {
throw new Error('文件太小,无法解析')
}
const header = bytes.subarray(0, 0x0f)
const data = bytes.subarray(0x0f)
const aesSize = this.bytesToInt32(header.subarray(6, 10))
const xorSize = this.bytesToInt32(header.subarray(10, 14))
// AES 数据需要对齐到 16 字节(PKCS7 填充)
// 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > data.length) {
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
}
const aesData = data.subarray(0, alignedAesSize)
let unpadded: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
// 使用 PKCS7 填充移除
unpadded = this.strictRemovePadding(decrypted)
}
const remaining = data.subarray(alignedAesSize)
if (xorSize < 0 || xorSize > remaining.length) {
throw new Error('文件格式异常:XOR 数据长度不合法')
}
let rawData = Buffer.alloc(0)
let xoredData = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常:原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength)
const xorData = remaining.subarray(rawLength)
xoredData = Buffer.alloc(xorData.length)
for (let i = 0; i < xorData.length; i += 1) {
xoredData[i] = xorData[i] ^ xorKey
}
} else {
rawData = remaining
xoredData = Buffer.alloc(0)
}
return Buffer.concat([unpadded, rawData, xoredData])
}
private bytesToInt32(bytes: Buffer): number {
if (bytes.length !== 4) {
throw new Error('需要 4 个字节')
}
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
}
asciiKey16(keyString: string): Buffer {
if (keyString.length < 16) {
throw new Error('AES密钥至少需要 16 个字符')
}
return Buffer.from(keyString, 'ascii').subarray(0, 16)
}
private strictRemovePadding(data: Buffer): Buffer {
if (!data.length) {
throw new Error('解密结果为空,填充非法')
}
const paddingLength = data[data.length - 1]
if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) {
throw new Error('PKCS7 填充长度非法')
}
for (let i = data.length - paddingLength; i < data.length; i += 1) {
if (data[i] !== paddingLength) {
throw new Error('PKCS7 填充内容非法')
private tryDecryptDatWithNative(
datPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
const result = decryptDatViaNative(datPath, xorKey, aesKey)
if (!this.nativeLogged) {
this.nativeLogged = true
if (result) {
this.logInfo('Rust 原生解密已启用', {
addonPath: nativeAddonLocation(),
source: 'native'
})
} else {
this.logInfo('Rust 原生解密不可用', {
addonPath: nativeAddonLocation(),
source: 'native_unavailable'
})
}
}
return data.subarray(0, data.length - paddingLength)
return result
}
private detectImageExtension(buffer: Buffer): string | null {
@@ -1723,91 +1583,6 @@ export class ImageDecryptService {
return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp'
}
private compareBytes(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false
}
return true
}
// 保留原有的批量检测 XOR 密钥方法(用于兼容)
async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise<number | null> {
const keyCount: Map<number, number> = new Map()
let filesChecked = 0
const V1_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
const V2_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
const IMAGE_SIGNATURES: { [key: string]: Buffer } = {
jpg: Buffer.from([0xFF, 0xD8, 0xFF]),
png: Buffer.from([0x89, 0x50, 0x4E, 0x47]),
gif: Buffer.from([0x47, 0x49, 0x46, 0x38]),
bmp: Buffer.from([0x42, 0x4D]),
webp: Buffer.from([0x52, 0x49, 0x46, 0x46])
}
const detectXorKeyFromV3 = (header: Buffer): number | null => {
for (const [, signature] of Object.entries(IMAGE_SIGNATURES)) {
const xorKey = header[0] ^ signature[0]
let valid = true
for (let i = 0; i < signature.length && i < header.length; i++) {
if ((header[i] ^ xorKey) !== signature[i]) {
valid = false
break
}
}
if (valid) return xorKey
}
return null
}
const scanDir = (dir: string) => {
if (filesChecked >= maxFiles) return
try {
const entries = readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (filesChecked >= maxFiles) return
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
scanDir(fullPath)
} else if (entry.name.endsWith('.dat')) {
try {
const header = Buffer.alloc(16)
const fd = require('fs').openSync(fullPath, 'r')
require('fs').readSync(fd, header, 0, 16, 0)
require('fs').closeSync(fd)
if (header.subarray(0, 6).equals(V1_SIGNATURE) || header.subarray(0, 6).equals(V2_SIGNATURE)) {
continue
}
const key = detectXorKeyFromV3(header)
if (key !== null) {
keyCount.set(key, (keyCount.get(key) || 0) + 1)
filesChecked++
}
} catch { }
}
}
} catch { }
}
scanDir(dirPath)
if (keyCount.size === 0) return null
let maxCount = 0
let mostCommonKey: number | null = null
keyCount.forEach((count, key) => {
if (count > maxCount) {
maxCount = count
mostCommonKey = key
}
})
return mostCommonKey
}
/**
* 解包 wxgf 格式
* wxgf 是微信的图片格式,内部使用 HEVC 编码
@@ -1857,45 +1632,39 @@ export class ImageDecryptService {
* 从 wxgf 数据中提取 HEVC NALU 裸流
*/
private extractHevcNalu(buffer: Buffer): Buffer | null {
const nalUnits: Buffer[] = []
const starts: number[] = []
let i = 4
while (i < buffer.length - 4) {
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
let nalStart = i
let nalEnd = buffer.length
while (i < buffer.length - 3) {
const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01
const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
buffer[i + 2] === 0x01
for (let j = i + 4; j < buffer.length - 3; j++) {
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
if (buffer[j + 2] === 0x01 ||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
nalEnd = j
break
}
}
}
const nalUnit = buffer.subarray(nalStart, nalEnd)
if (nalUnit.length > 3) {
nalUnits.push(nalUnit)
}
i = nalEnd
} else {
i++
if (hasPrefix4 || hasPrefix3) {
starts.push(i)
i += hasPrefix4 ? 4 : 3
continue
}
i += 1
}
if (nalUnits.length === 0) {
for (let j = 4; j < buffer.length - 4; j++) {
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
return buffer.subarray(j)
}
}
return null
if (starts.length === 0) return null
const nalUnits: Buffer[] = []
for (let index = 0; index < starts.length; index += 1) {
const start = starts[index]
const end = index + 1 < starts.length ? starts[index + 1] : buffer.length
const hasPrefix4 = buffer[start] === 0x00 && buffer[start + 1] === 0x00 &&
buffer[start + 2] === 0x00 && buffer[start + 3] === 0x01
const prefixLength = hasPrefix4 ? 4 : 3
const payloadStart = start + prefixLength
if (payloadStart >= end) continue
nalUnits.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
nalUnits.push(buffer.subarray(payloadStart, end))
}
if (nalUnits.length === 0) return null
return Buffer.concat(nalUnits)
}
@@ -1921,11 +1690,11 @@ export class ImageDecryptService {
const ffmpeg = this.getFfmpegPath()
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
const tmpDir = join(this.getTempPath(), 'weflow_hevc')
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
const ts = Date.now()
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
const uniqueId = `${process.pid}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
const tmpInput = join(tmpDir, `hevc_${uniqueId}.hevc`)
const tmpOutput = join(tmpDir, `hevc_${uniqueId}.jpg`)
try {
await writeFile(tmpInput, hevcData)
@@ -1933,6 +1702,7 @@ export class ImageDecryptService {
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
const attempts: { label: string; inputArgs: string[] }[] = [
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
{ label: 'h265 raw', inputArgs: ['-f', 'h265', '-i', tmpInput] },
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
]
@@ -1961,6 +1731,7 @@ export class ImageDecryptService {
const args = [
'-hide_banner', '-loglevel', 'error',
'-y',
...inputArgs,
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
]
@@ -2015,11 +1786,6 @@ export class ImageDecryptService {
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
}
private hasXVariant(base: string): boolean {
const lower = base.toLowerCase()
return this.stripDatVariantSuffix(lower) !== lower
}
private isHdPath(p: string): boolean {
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
}
@@ -2044,42 +1810,43 @@ export class ImageDecryptService {
}
}
// 保留原有的解密到文件方法(用于兼容)
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
const version = this.getDatVersion(inputPath)
let decrypted: Buffer
if (version === 0) {
decrypted = this.decryptDatV3(inputPath, xorKey)
} else if (version === 1) {
const key = this.asciiKey16(this.defaultV1AesKey)
decrypted = this.decryptDatV4(inputPath, xorKey, key)
} else {
if (!aesKey || aesKey.length !== 16) {
throw new Error('V4版本需要 16 字节 AES 密钥')
}
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null {
try {
const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath
if (typeof getter !== 'function') return null
const value = getter(name)
return typeof value === 'string' && value.trim() ? value : null
} catch {
return null
}
}
const outputDir = dirname(outputPath)
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true })
}
private getUserDataPath(): string {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
if (workerUserDataPath) return workerUserDataPath
return this.getElectronPath('userData') || process.cwd()
}
await writeFile(outputPath, decrypted)
private getDocumentsPath(): string {
return this.getElectronPath('documents') || join(homedir(), 'Documents')
}
private getTempPath(): string {
return this.getElectronPath('temp') || tmpdir()
}
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.resolvedCache.clear()
this.pending.clear()
this.updateFlags.clear()
this.cacheIndexed = false
this.cacheIndexing = null
this.accountDirCache.clear()
this.ensuredDirs.clear()
this.cacheRootPath = null
const configured = this.configService.get('cachePath')
const root = configured
? join(configured, 'Images')
: join(app.getPath('documents'), 'WeFlow', 'Images')
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
try {
if (!existsSync(root)) {
+12 -2
View File
@@ -4,6 +4,7 @@ type PreloadImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
}
type PreloadOptions = {
@@ -74,15 +75,24 @@ export class ImagePreloadService {
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: !task.allowDecrypt,
allowCacheIndex: task.allowCacheIndex
allowCacheIndex: task.allowCacheIndex,
suppressEvents: true
})
if (cached.success) return
if (!task.allowDecrypt) return
await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
} catch {
// ignore preload failures
-2
View File
@@ -478,8 +478,6 @@ export class KeyServiceMac {
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
'end try'
]
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
let stdout = ''
try {
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
+7
View File
@@ -53,6 +53,13 @@ class MessagePushService {
void this.refreshConfiguration('startup')
}
stop(): void {
this.started = false
this.processing = false
this.rerunRequested = false
this.resetRuntimeState()
}
handleDbMonitorChange(type: string, json: string): void {
if (!this.started) return
if (!this.isPushEnabled()) return
+110
View File
@@ -0,0 +1,110 @@
import { existsSync } from 'fs'
import { join } from 'path'
type NativeDecryptResult = {
data: Buffer
ext: string
isWxgf?: boolean
is_wxgf?: boolean
}
type NativeAddon = {
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
}
let cachedAddon: NativeAddon | null | undefined
function shouldEnableNative(): boolean {
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
}
function expandAsarCandidates(filePath: string): string[] {
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
return [filePath]
}
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
}
function getPlatformDir(): string {
if (process.platform === 'win32') return 'win32'
if (process.platform === 'darwin') return 'macos'
if (process.platform === 'linux') return 'linux'
return process.platform
}
function getArchDir(): string {
if (process.arch === 'x64') return 'x64'
if (process.arch === 'arm64') return 'arm64'
return process.arch
}
function getAddonCandidates(): string[] {
const platformDir = getPlatformDir()
const archDir = getArchDir()
const cwd = process.cwd()
const fileNames = [
`weflow-image-native-${platformDir}-${archDir}.node`
]
const roots = [
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
...(process.resourcesPath
? [
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
]
: [])
]
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
}
function loadAddon(): NativeAddon | null {
if (!shouldEnableNative()) return null
if (cachedAddon !== undefined) return cachedAddon
for (const candidate of getAddonCandidates()) {
if (!existsSync(candidate)) continue
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const addon = require(candidate) as NativeAddon
if (addon && typeof addon.decryptDatNative === 'function') {
cachedAddon = addon
return addon
}
} catch {
// try next candidate
}
}
cachedAddon = null
return null
}
export function nativeAddonLocation(): string | null {
for (const candidate of getAddonCandidates()) {
if (existsSync(candidate)) return candidate
}
return null
}
export function decryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
const addon = loadAddon()
if (!addon) return null
try {
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
if (!result || !Buffer.isBuffer(result.data)) return null
const rawExt = typeof result.ext === 'string' && result.ext.trim()
? result.ext.trim().toLowerCase()
: ''
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
return { data: result.data, ext, isWxgf }
} catch {
return null
}
}
+5 -1
View File
@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
import * as fzstd from 'fzstd'
import { expandHomePath } from '../utils/pathUtils'
//数据服务初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
@@ -481,7 +482,7 @@ export class WcdbCore {
private resolveDbStoragePath(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\\\/]+$/, '')
const normalized = expandHomePath(basePath).replace(/[\\\\/]+$/, '')
if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) {
return normalized
}
@@ -1600,6 +1601,9 @@ export class WcdbCore {
*/
close(): void {
if (this.handle !== null || this.initialized) {
// 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。
try { this.stopMonitor() } catch {}
try { this.cloudStop() } catch {}
try {
// 不调用 closeAccount,直接 shutdown
this.wcdbShutdown()
+20
View File
@@ -0,0 +1,20 @@
import { homedir } from 'os'
/**
* Expand "~" prefix to current user's home directory.
* Examples:
* - "~" => "/Users/alex"
* - "~/Library/..." => "/Users/alex/Library/..."
*/
export function expandHomePath(inputPath: string): string {
const raw = String(inputPath || '').trim()
if (!raw) return raw
if (raw === '~') return homedir()
if (/^~[\\/]/.test(raw)) {
return `${homedir()}${raw.slice(1)}`
}
return raw
}
+15 -14
View File
@@ -9,7 +9,6 @@
"version": "4.3.0",
"hasInstallScript": true,
"dependencies": {
"@vscode/sudo-prompt": "^9.3.2",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"electron-store": "^11.0.2",
@@ -28,8 +27,9 @@
"react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.12.35",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"sudo-prompt": "^9.2.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},
@@ -40,11 +40,11 @@
"@vitejs/plugin-react": "^4.3.4",
"electron": "^41.1.1",
"electron-builder": "^26.8.1",
"sass": "^1.99.0",
"sass": "^1.98.0",
"sharp": "^0.34.5",
"typescript": "^6.0.2",
"vite": "^7.3.2",
"vite-plugin-electron": "^0.29.1",
"vite": "^7.0.0",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6"
}
},
@@ -3050,12 +3050,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vscode/sudo-prompt": {
"version": "9.3.2",
"resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz",
"integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==",
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
@@ -9462,6 +9456,13 @@
"inline-style-parser": "0.2.7"
}
},
"node_modules/sudo-prompt": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -10140,9 +10141,9 @@
}
},
"node_modules/vite-plugin-electron": {
"version": "0.29.1",
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz",
"integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==",
"version": "0.28.8",
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz",
"integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
+11 -10
View File
@@ -9,7 +9,7 @@
},
"repository": {
"type": "git",
"url": "https://github.com/Jasonzhu1207/WeFlow"
"url": "https://github.com/hicccc77/WeFlow"
},
"//": "二改不应改变此处的作者与应用信息",
"scripts": {
@@ -77,7 +77,7 @@
"appId": "com.WeFlow.app",
"publish": {
"provider": "github",
"owner": "Jasonzhu1207",
"owner": "hicccc77",
"repo": "WeFlow",
"releaseType": "release"
},
@@ -96,7 +96,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": [
@@ -105,19 +105,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": "."
}
]
@@ -133,7 +133,7 @@
"synopsis": "WeFlow for Linux",
"extraFiles": [
{
"from": "resources/linux/install.sh",
"from": "resources/installer/linux/install.sh",
"to": "install.sh"
}
]
@@ -186,9 +186,10 @@
"node_modules/sherpa-onnx-node/**/*",
"node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*"
"node_modules/ffmpeg-static/**/*",
"resources/wedecrypt/**/*.node"
],
"icon": "resources/icon.icns"
"icon": "resources/icons/macos/icon.icns"
},
"overrides": {
"picomatch": "^4.0.4",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-6
View File
@@ -39,8 +39,6 @@ import UpdateDialog from './components/UpdateDialog'
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
import WindowCloseDialog from './components/WindowCloseDialog'
function RouteStateRedirect({ to }: { to: string }) {
@@ -554,10 +552,6 @@ function App() {
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
<BatchImageDecryptGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">
+377 -97
View File
@@ -29,6 +29,7 @@ import {
onSingleExportDialogStatus,
requestExportSessionStatus
} from '../services/exportBridge'
import '../styles/batchTranscribe.scss'
import './ChatPage.scss'
// 系统消息类型常量
@@ -154,6 +155,21 @@ function hasRenderableChatRecordName(value?: string): boolean {
return value !== undefined && value !== null && String(value).length > 0
}
function toRenderableImageSrc(path?: string): string | undefined {
const raw = String(path || '').trim()
if (!raw) return undefined
if (/^(data:|blob:|https?:|file:)/i.test(raw)) return raw
const normalized = raw.replace(/\\/g, '/')
if (/^[a-zA-Z]:\//.test(normalized)) {
return encodeURI(`file:///${normalized}`)
}
if (normalized.startsWith('/')) {
return encodeURI(`file://${normalized}`)
}
return raw
}
function getChatRecordPreviewText(item: ChatRecordItem): string {
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
if (item.datatype === 17) {
@@ -1355,34 +1371,30 @@ function ChatPage(props: ChatPageProps) {
const {
isBatchTranscribing,
runningBatchVoiceTaskType,
batchTranscribeProgress,
startTranscribe,
updateProgress,
finishTranscribe,
setShowBatchProgress
updateTranscribeTaskStatus,
finishTranscribe
} = useBatchTranscribeStore(useShallow((state) => ({
isBatchTranscribing: state.isBatchTranscribing,
runningBatchVoiceTaskType: state.taskType,
batchTranscribeProgress: state.progress,
startTranscribe: state.startTranscribe,
updateProgress: state.updateProgress,
finishTranscribe: state.finishTranscribe,
setShowBatchProgress: state.setShowToast
updateTranscribeTaskStatus: state.setTaskStatus,
finishTranscribe: state.finishTranscribe
})))
const {
isBatchDecrypting,
batchDecryptProgress,
startDecrypt,
updateDecryptProgress,
finishDecrypt,
setShowBatchDecryptToast
updateDecryptTaskStatus,
finishDecrypt
} = useBatchImageDecryptStore(useShallow((state) => ({
isBatchDecrypting: state.isBatchDecrypting,
batchDecryptProgress: state.progress,
startDecrypt: state.startDecrypt,
updateDecryptProgress: state.updateProgress,
finishDecrypt: state.finishDecrypt,
setShowBatchDecryptToast: state.setShowToast
updateDecryptTaskStatus: state.setTaskStatus,
finishDecrypt: state.finishDecrypt
})))
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
@@ -4853,7 +4865,7 @@ function ChatPage(props: ChatPageProps) {
const candidates = [...head, ...tail]
const queued = preloadImageKeysRef.current
const seen = new Set<string>()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
for (const msg of candidates) {
if (payloads.length >= maxPreload) break
if (msg.localType !== 3) continue
@@ -4867,11 +4879,14 @@ function ChatPage(props: ChatPageProps) {
payloads.push({
sessionId: currentSessionId,
imageMd5: msg.imageMd5 || undefined,
imageDatName: msg.imageDatName
imageDatName: msg.imageDatName,
createTime: msg.createTime
})
}
if (payloads.length > 0) {
window.electronAPI.image.preload(payloads).catch(() => { })
window.electronAPI.image.preload(payloads, {
allowCacheIndex: false
}).catch(() => { })
}
}, [currentSessionId, messages])
@@ -5712,22 +5727,74 @@ function ChatPage(props: ChatPageProps) {
if (!session) return
const taskType = batchVoiceTaskType
startTranscribe(voiceMessages.length, session.displayName || session.username, taskType)
if (taskType === 'transcribe') {
// 检查模型状态
const modelStatus = await window.electronAPI.whisper.getModelStatus()
if (!modelStatus?.exists) {
alert('SenseVoice 模型未下载,请先在设置中下载模型')
finishTranscribe(0, 0)
return
}
}
const totalVoices = voiceMessages.length
const taskVerb = taskType === 'decrypt' ? '语音解密' : '语音转写'
let successCount = 0
let failCount = 0
let completedCount = 0
const concurrency = taskType === 'decrypt' ? 12 : 10
const controlState = {
cancelRequested: false,
pauseRequested: false,
pauseAnnounced: false,
resumeWaiters: [] as Array<() => void>
}
const resolveResumeWaiters = () => {
const waiters = [...controlState.resumeWaiters]
controlState.resumeWaiters.length = 0
waiters.forEach(resolve => resolve())
}
const waitIfPaused = async () => {
while (controlState.pauseRequested && !controlState.cancelRequested) {
if (!controlState.pauseAnnounced) {
controlState.pauseAnnounced = true
updateTranscribeTaskStatus(
`${taskVerb}任务已中断,等待继续...`,
`${completedCount} / ${totalVoices}`,
'paused'
)
}
await new Promise<void>(resolve => {
controlState.resumeWaiters.push(resolve)
})
}
if (controlState.pauseAnnounced && !controlState.cancelRequested) {
controlState.pauseAnnounced = false
updateTranscribeTaskStatus(
`继续${taskVerb}(${completedCount}/${totalVoices})`,
`${completedCount} / ${totalVoices}`,
'running'
)
}
}
startTranscribe(totalVoices, session.displayName || session.username, taskType, 'chat', {
cancelable: true,
resumable: true,
onPause: () => {
controlState.pauseRequested = true
updateTranscribeTaskStatus(
`${taskVerb}中断请求已发出,当前处理完成后暂停...`,
`${completedCount} / ${totalVoices}`,
'pause_requested'
)
},
onResume: () => {
controlState.pauseRequested = false
resolveResumeWaiters()
},
onCancel: () => {
controlState.cancelRequested = true
controlState.pauseRequested = false
resolveResumeWaiters()
updateTranscribeTaskStatus(
`${taskVerb}停止请求已发出,当前处理完成后结束...`,
`${completedCount} / ${totalVoices}`,
'cancel_requested'
)
}
})
updateTranscribeTaskStatus(`正在准备${taskVerb}任务...`, `0 / ${totalVoices}`, 'running')
const runOne = async (msg: Message) => {
try {
@@ -5751,20 +5818,74 @@ function ChatPage(props: ChatPageProps) {
}
}
for (let i = 0; i < voiceMessages.length; i += concurrency) {
const batch = voiceMessages.slice(i, i + concurrency)
const results = await Promise.all(batch.map(msg => runOne(msg)))
try {
if (taskType === 'transcribe') {
updateTranscribeTaskStatus('正在检查转写模型...', `0 / ${totalVoices}`)
const modelStatus = await window.electronAPI.whisper.getModelStatus()
if (!modelStatus?.exists) {
alert('SenseVoice 模型未下载,请先在设置中下载模型')
updateTranscribeTaskStatus('转写模型缺失,任务已停止', `0 / ${totalVoices}`)
finishTranscribe(0, totalVoices)
return
}
}
results.forEach(result => {
updateTranscribeTaskStatus(`正在${taskVerb}(0/${totalVoices})`, `0 / ${totalVoices}`)
const pool = new Set<Promise<void>>()
const runOneTracked = async (msg: Message) => {
if (controlState.cancelRequested) return
const result = await runOne(msg)
if (result.success) successCount++
else failCount++
completedCount++
updateProgress(completedCount, voiceMessages.length)
})
}
updateProgress(completedCount, totalVoices)
}
finishTranscribe(successCount, failCount)
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe])
for (const msg of voiceMessages) {
if (controlState.cancelRequested) break
await waitIfPaused()
if (controlState.cancelRequested) break
if (pool.size >= concurrency) {
await Promise.race(pool)
if (controlState.cancelRequested) break
await waitIfPaused()
if (controlState.cancelRequested) break
}
let p: Promise<void> = Promise.resolve()
p = runOneTracked(msg).finally(() => {
pool.delete(p)
})
pool.add(p)
}
while (pool.size > 0) {
await Promise.race(pool)
}
if (controlState.cancelRequested) {
const remaining = Math.max(0, totalVoices - completedCount)
finishTranscribe(successCount, failCount, {
status: 'canceled',
detail: `${taskVerb}任务已中断:已完成 ${completedCount}/${totalVoices}(成功 ${successCount},失败 ${failCount},未处理 ${remaining})`,
progressText: `${completedCount} / ${totalVoices}`
})
return
}
finishTranscribe(successCount, failCount, {
status: failCount > 0 ? 'failed' : 'completed'
})
} catch (error) {
const remaining = Math.max(0, totalVoices - completedCount)
failCount += remaining
updateTranscribeTaskStatus(`${taskVerb}过程中发生异常,正在结束任务...`, `${completedCount} / ${totalVoices}`)
finishTranscribe(successCount, failCount, {
status: 'failed'
})
alert(`批量${taskVerb}失败:${String(error)}`)
}
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateTranscribeTaskStatus, updateProgress, finishTranscribe])
// 批量转写:按日期的消息数量
const batchCountByDate = useMemo(() => {
@@ -5827,45 +5948,175 @@ function ChatPage(props: ChatPageProps) {
setBatchImageDates([])
setBatchImageSelectedDates(new Set())
startDecrypt(images.length, session.displayName || session.username)
const totalImages = images.length
let successCount = 0
let failCount = 0
let notFoundCount = 0
let decryptFailedCount = 0
let completed = 0
const controlState = {
cancelRequested: false,
pauseRequested: false,
pauseAnnounced: false,
resumeWaiters: [] as Array<() => void>
}
const resolveResumeWaiters = () => {
const waiters = [...controlState.resumeWaiters]
controlState.resumeWaiters.length = 0
waiters.forEach(resolve => resolve())
}
const waitIfPaused = async () => {
while (controlState.pauseRequested && !controlState.cancelRequested) {
if (!controlState.pauseAnnounced) {
controlState.pauseAnnounced = true
updateDecryptTaskStatus(
'图片批量解密任务已中断,等待继续...',
`${completed} / ${totalImages}`,
'paused'
)
}
await new Promise<void>(resolve => {
controlState.resumeWaiters.push(resolve)
})
}
if (controlState.pauseAnnounced && !controlState.cancelRequested) {
controlState.pauseAnnounced = false
updateDecryptTaskStatus(
`继续批量解密图片(${completed}/${totalImages})`,
`${completed} / ${totalImages}`,
'running'
)
}
}
startDecrypt(totalImages, session.displayName || session.username, 'chat', {
cancelable: true,
resumable: true,
onPause: () => {
controlState.pauseRequested = true
updateDecryptTaskStatus(
'图片解密中断请求已发出,当前处理完成后暂停...',
`${completed} / ${totalImages}`,
'pause_requested'
)
},
onResume: () => {
controlState.pauseRequested = false
resolveResumeWaiters()
},
onCancel: () => {
controlState.cancelRequested = true
controlState.pauseRequested = false
resolveResumeWaiters()
updateDecryptTaskStatus(
'图片解密停止请求已发出,当前处理完成后结束...',
`${completed} / ${totalImages}`,
'cancel_requested'
)
}
})
updateDecryptTaskStatus('正在准备批量图片解密任务...', `0 / ${totalImages}`, 'running')
const hardlinkMd5Set = new Set<string>()
for (const img of images) {
const imageMd5 = String(img.imageMd5 || '').trim().toLowerCase()
if (imageMd5) {
hardlinkMd5Set.add(imageMd5)
continue
}
const imageDatName = String(img.imageDatName || '').trim().toLowerCase()
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
hardlinkMd5Set.add(imageDatName)
}
}
if (hardlinkMd5Set.size > 0) {
await waitIfPaused()
if (controlState.cancelRequested) {
const remaining = Math.max(0, totalImages - completed)
finishDecrypt(successCount, failCount, {
status: 'canceled',
detail: `图片批量解密已中断:已处理 ${completed}/${totalImages}(成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount},未处理 ${remaining})`,
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
})
return
}
updateDecryptTaskStatus(
`正在预热图片索引(${hardlinkMd5Set.size} 个标识)...`,
`0 / ${totalImages}`
)
try {
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set))
} catch {
// ignore preload failures and continue decrypt
}
}
updateDecryptTaskStatus(`开始批量解密图片(0/${totalImages})`, `0 / ${totalImages}`)
const concurrency = batchDecryptConcurrency
const decryptOne = async (img: typeof images[0]) => {
if (controlState.cancelRequested) return
try {
const r = await window.electronAPI.image.decrypt({
sessionId: session.username,
imageMd5: img.imageMd5,
imageDatName: img.imageDatName,
force: true
createTime: img.createTime,
force: true,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
if (r?.success) successCount++
else failCount++
else {
failCount++
if (r?.failureKind === 'decrypt_failed') decryptFailedCount++
else notFoundCount++
}
} catch {
failCount++
notFoundCount++
}
completed++
updateDecryptProgress(completed, images.length)
updateDecryptProgress(completed, totalImages)
}
// 并发池:同时跑 concurrency 个任务
const pool = new Set<Promise<void>>()
for (const img of images) {
const p = decryptOne(img).then(() => { pool.delete(p) })
pool.add(p)
if (controlState.cancelRequested) break
await waitIfPaused()
if (controlState.cancelRequested) break
if (pool.size >= concurrency) {
await Promise.race(pool)
if (controlState.cancelRequested) break
await waitIfPaused()
if (controlState.cancelRequested) break
}
let p: Promise<void> = Promise.resolve()
p = decryptOne(img).then(() => { pool.delete(p) })
pool.add(p)
}
if (pool.size > 0) {
await Promise.all(pool)
while (pool.size > 0) {
await Promise.race(pool)
}
finishDecrypt(successCount, failCount)
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
if (controlState.cancelRequested) {
const remaining = Math.max(0, totalImages - completed)
finishDecrypt(successCount, failCount, {
status: 'canceled',
detail: `图片批量解密已中断:已处理 ${completed}/${totalImages}(成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount},未处理 ${remaining})`,
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
})
return
}
finishDecrypt(successCount, failCount, {
status: decryptFailedCount > 0 ? 'failed' : 'completed',
detail: `图片批量解密完成:成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount}`,
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
})
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptTaskStatus, updateDecryptProgress])
const batchImageCountByDate = useMemo(() => {
const map = new Map<string, number>()
@@ -6600,16 +6851,10 @@ function ChatPage(props: ChatPageProps) {
{!standaloneSessionWindow && (
<button
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchTranscribing) {
setShowBatchProgress(true)
} else {
handleBatchTranscribe()
}
}}
onClick={handleBatchTranscribe}
disabled={!currentSessionId}
title={isBatchTranscribing
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度`
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中,可在导出页任务中心查看进度`
: '批量语音处理(解密/转文字)'}
>
{isBatchTranscribing ? (
@@ -6622,16 +6867,10 @@ function ChatPage(props: ChatPageProps) {
{!standaloneSessionWindow && (
<button
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchDecrypting) {
setShowBatchDecryptToast(true)
} else {
handleBatchDecrypt()
}
}}
onClick={handleBatchDecrypt}
disabled={!currentSessionId}
title={isBatchDecrypting
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
? '批量解密中,可在导出页任务中心查看进度'
: '批量解密图片'}
>
{isBatchDecrypting ? (
@@ -7309,8 +7548,8 @@ function ChatPage(props: ChatPageProps) {
<AlertCircle size={16} />
<span>
{batchVoiceTaskType === 'decrypt'
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。'
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'}
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能,进度会写入导出页任务中心。'
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过,进度会写入导出页任务中心。'}
</span>
</div>
</div>
@@ -7377,17 +7616,17 @@ function ChatPage(props: ChatPageProps) {
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)}
>
<span>{batchDecryptConcurrency === 1 ? '1(最慢,最稳)' : batchDecryptConcurrency === 6 ? '6(推荐)' : batchDecryptConcurrency === 20 ? '20(最快,可能卡顿)' : String(batchDecryptConcurrency)}</span>
<span>{batchDecryptConcurrency === 1 ? '1' : batchDecryptConcurrency === 6 ? '6' : batchDecryptConcurrency === 20 ? '20' : String(batchDecryptConcurrency)}</span>
<ChevronDown size={14} />
</button>
{showConcurrencyDropdown && (
<div className="batch-concurrency-dropdown">
{[
{ value: 1, label: '1(最慢,最稳)' },
{ value: 1, label: '1' },
{ value: 3, label: '3' },
{ value: 6, label: '6(推荐)' },
{ value: 6, label: '6' },
{ value: 10, label: '10' },
{ value: 20, label: '20(最快,可能卡顿)' },
{ value: 20, label: '20' },
].map(opt => (
<button
key={opt.value}
@@ -7405,7 +7644,7 @@ function ChatPage(props: ChatPageProps) {
</div>
<div className="batch-warning">
<AlertCircle size={16} />
<span>批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。</span>
<span>批量解密可能需要较长时间,进度会自动写入导出页任务中心(含准备阶段状态)。</span>
</div>
</div>
<div className="batch-modal-footer">
@@ -7768,7 +8007,13 @@ const emojiDataUrlCache = new Map<string, string>()
const imageDataUrlCache = new Map<string, string>()
const voiceDataUrlCache = new Map<string, string>()
const voiceTranscriptCache = new Map<string, string>()
type SharedImageDecryptResult = { success: boolean; localPath?: string; liveVideoPath?: string; error?: string }
type SharedImageDecryptResult = {
success: boolean
localPath?: string
liveVideoPath?: string
error?: string
failureKind?: 'not_found' | 'decrypt_failed'
}
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
@@ -7882,7 +8127,7 @@ function MessageBubble({
)
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
() => imageDataUrlCache.get(imageCacheKey)
() => toRenderableImageSrc(imageDataUrlCache.get(imageCacheKey))
)
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
const voiceCacheKey = `voice:${voiceIdentityKey}`
@@ -7904,6 +8149,7 @@ function MessageBubble({
const imageUpdateCheckedRef = useRef<string | null>(null)
const imageClickTimerRef = useRef<number | null>(null)
const imageContainerRef = useRef<HTMLDivElement>(null)
const imageElementRef = useRef<HTMLImageElement | null>(null)
const emojiContainerRef = useRef<HTMLDivElement>(null)
const imageResizeBaselineRef = useRef<number | null>(null)
const emojiResizeBaselineRef = useRef<number | null>(null)
@@ -8260,19 +8506,27 @@ function MessageBubble({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName,
force: forceUpdate
createTime: message.createTime,
force: forceUpdate,
preferFilePath: true,
hardlinkOnly: true
}) as SharedImageDecryptResult
})
if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath)
if (imageLocalPath !== result.localPath) {
const renderPath = toRenderableImageSrc(result.localPath)
if (!renderPath) {
if (!silent) setImageError(true)
return { success: false }
}
imageDataUrlCache.set(imageCacheKey, renderPath)
if (imageLocalPath !== renderPath) {
captureImageResizeBaseline()
lockImageStageHeight()
}
setImageLocalPath(result.localPath)
setImageLocalPath(renderPath)
setImageHasUpdate(false)
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
return result
return { ...result, localPath: renderPath }
}
}
@@ -8297,7 +8551,7 @@ function MessageBubble({
imageDecryptPendingRef.current = false
}
return { success: false }
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
const triggerForceHd = useCallback(() => {
if (!message.imageMd5 && !message.imageDatName) return
@@ -8352,24 +8606,29 @@ function MessageBubble({
const resolved = await window.electronAPI.image.resolveCache({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName
imageDatName: message.imageDatName,
createTime: message.createTime,
preferFilePath: true,
hardlinkOnly: true
})
if (resolved?.success && resolved.localPath) {
finalImagePath = resolved.localPath
const renderPath = toRenderableImageSrc(resolved.localPath)
if (!renderPath) return
finalImagePath = renderPath
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
if (imageLocalPath !== resolved.localPath) {
imageDataUrlCache.set(imageCacheKey, renderPath)
if (imageLocalPath !== renderPath) {
captureImageResizeBaseline()
lockImageStageHeight()
}
setImageLocalPath(resolved.localPath)
setImageLocalPath(renderPath)
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
setImageHasUpdate(Boolean(resolved.hasUpdate))
}
} catch { }
}
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
void window.electronAPI.window.openImageViewerWindow(toRenderableImageSrc(finalImagePath) || finalImagePath, finalLiveVideoPath)
}, [
imageLiveVideoPath,
imageLocalPath,
@@ -8378,6 +8637,7 @@ function MessageBubble({
lockImageStageHeight,
message.imageDatName,
message.imageMd5,
message.createTime,
requestImageDecrypt,
session.username
])
@@ -8391,8 +8651,19 @@ function MessageBubble({
}, [])
useEffect(() => {
setImageLoaded(false)
}, [imageLocalPath])
if (!isImage) return
if (!imageLocalPath) {
setImageLoaded(false)
return
}
// 某些 file:// 缓存图在 src 切换时可能不会稳定触发 onLoad,
// 这里用 complete/naturalWidth 做一次兜底,避免图片进入 pending 隐身态。
const img = imageElementRef.current
if (img && img.complete && img.naturalWidth > 0) {
setImageLoaded(true)
}
}, [isImage, imageLocalPath])
useEffect(() => {
if (imageLoading) return
@@ -8401,7 +8672,7 @@ function MessageBubble({
}, [imageError, imageLoading, imageLocalPath])
useEffect(() => {
if (!isImage || imageLoading) return
if (!isImage || imageLoading || !imageInView) return
if (!message.imageMd5 && !message.imageDatName) return
if (imageUpdateCheckedRef.current === imageCacheKey) return
imageUpdateCheckedRef.current = imageCacheKey
@@ -8409,15 +8680,21 @@ function MessageBubble({
window.electronAPI.image.resolveCache({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName
imageDatName: message.imageDatName,
createTime: message.createTime,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: false
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
if (cancelled) return
if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath)
if (!imageLocalPath || imageLocalPath !== result.localPath) {
const renderPath = toRenderableImageSrc(result.localPath)
if (!renderPath) return
imageDataUrlCache.set(imageCacheKey, renderPath)
if (!imageLocalPath || imageLocalPath !== renderPath) {
captureImageResizeBaseline()
lockImageStageHeight()
setImageLocalPath(result.localPath)
setImageLocalPath(renderPath)
setImageError(false)
}
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
@@ -8427,7 +8704,7 @@ function MessageBubble({
return () => {
cancelled = true
}
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, message.createTime, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
useEffect(() => {
if (!isImage) return
@@ -8455,15 +8732,17 @@ function MessageBubble({
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
(payload.imageDatName && payload.imageDatName === message.imageDatName)
if (matchesCacheKey) {
const renderPath = toRenderableImageSrc(payload.localPath)
if (!renderPath) return
const cachedPath = imageDataUrlCache.get(imageCacheKey)
if (cachedPath !== payload.localPath) {
imageDataUrlCache.set(imageCacheKey, payload.localPath)
if (cachedPath !== renderPath) {
imageDataUrlCache.set(imageCacheKey, renderPath)
}
if (imageLocalPath !== payload.localPath) {
if (imageLocalPath !== renderPath) {
captureImageResizeBaseline()
lockImageStageHeight()
}
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath))
setImageLocalPath((prev) => (prev === renderPath ? prev : renderPath))
setImageError(false)
}
})
@@ -9093,6 +9372,7 @@ function MessageBubble({
<>
<div className="image-message-wrapper">
<img
ref={imageElementRef}
src={imageLocalPath}
alt="图片"
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
+15 -3
View File
@@ -410,6 +410,17 @@
background: rgba(245, 158, 11, 0.14);
}
&.status-pause_requested,
&.status-paused {
color: #b45309;
background: rgba(245, 158, 11, 0.14);
}
&.status-canceled {
color: #64748b;
background: rgba(148, 163, 184, 0.2);
}
&.status-completed {
color: #166534;
background: rgba(34, 197, 94, 0.14);
@@ -5817,8 +5828,9 @@
}
}
/* 终止时间选择器 */
.automation-stopat-picker {
/* 首次触发/终止时间选择器 */
.automation-stopat-picker,
.automation-first-trigger-picker {
display: flex;
align-items: center;
gap: 8px;
@@ -5884,4 +5896,4 @@
font-size: 11px;
border-radius: 6px;
}
}
}
+461 -129
View File
@@ -22,6 +22,8 @@ import {
MessageSquare,
MessageSquareText,
Mic,
Pause,
Play,
RefreshCw,
Search,
Square,
@@ -48,6 +50,8 @@ import {
import {
requestCancelBackgroundTask,
requestCancelBackgroundTasks,
requestPauseBackgroundTask,
requestResumeBackgroundTask,
subscribeBackgroundTasks
} from '../services/backgroundTaskMonitor'
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
@@ -105,7 +109,6 @@ interface ExportOptions {
txtColumns: string[]
displayNamePreference: DisplayNamePreference
exportConcurrency: number
imageDeepSearchOnMiss: boolean
}
interface SessionRow extends AppChatSession {
@@ -209,6 +212,8 @@ interface AutomationTaskDraft {
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
intervalDays: number
intervalHours: number
firstTriggerAtEnabled: boolean
firstTriggerAtValue: string
stopAtEnabled: boolean
stopAtValue: string
maxRunsEnabled: boolean
@@ -218,6 +223,7 @@ interface AutomationTaskDraft {
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
const EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS = 320
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
@@ -249,6 +255,8 @@ const backgroundTaskSourceLabels: Record<string, string> = {
const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
running: '运行中',
pause_requested: '中断中',
paused: '已中断',
cancel_requested: '停止中',
completed: '已完成',
failed: '失败',
@@ -322,6 +330,69 @@ const createEmptyProgress = (): TaskProgress => ({
mediaBytesWritten: 0
})
const areStringArraysEqual = (left: string[], right: string[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false
}
return true
}
const areTaskProgressEqual = (left: TaskProgress, right: TaskProgress): boolean => (
left.current === right.current &&
left.total === right.total &&
left.currentName === right.currentName &&
left.phase === right.phase &&
left.phaseLabel === right.phaseLabel &&
left.phaseProgress === right.phaseProgress &&
left.phaseTotal === right.phaseTotal &&
left.exportedMessages === right.exportedMessages &&
left.estimatedTotalMessages === right.estimatedTotalMessages &&
left.collectedMessages === right.collectedMessages &&
left.writtenFiles === right.writtenFiles &&
left.mediaDoneFiles === right.mediaDoneFiles &&
left.mediaCacheHitFiles === right.mediaCacheHitFiles &&
left.mediaCacheMissFiles === right.mediaCacheMissFiles &&
left.mediaCacheFillFiles === right.mediaCacheFillFiles &&
left.mediaDedupReuseFiles === right.mediaDedupReuseFiles &&
left.mediaBytesWritten === right.mediaBytesWritten
)
const normalizeProgressFloat = (value: unknown, digits = 3): number => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
const factor = 10 ** digits
return Math.round(parsed * factor) / factor
}
const normalizeProgressInt = (value: unknown): number => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
return Math.max(0, Math.floor(parsed))
}
const buildProgressPayloadSignature = (payload: ExportProgress): string => ([
String(payload.phase || ''),
String(payload.currentSessionId || ''),
String(payload.currentSession || ''),
String(payload.phaseLabel || ''),
normalizeProgressFloat(payload.current, 4),
normalizeProgressFloat(payload.total, 4),
normalizeProgressFloat(payload.phaseProgress, 2),
normalizeProgressFloat(payload.phaseTotal, 2),
normalizeProgressInt(payload.collectedMessages),
normalizeProgressInt(payload.exportedMessages),
normalizeProgressInt(payload.estimatedTotalMessages),
normalizeProgressInt(payload.writtenFiles),
normalizeProgressInt(payload.mediaDoneFiles),
normalizeProgressInt(payload.mediaCacheHitFiles),
normalizeProgressInt(payload.mediaCacheMissFiles),
normalizeProgressInt(payload.mediaCacheFillFiles),
normalizeProgressInt(payload.mediaDedupReuseFiles),
normalizeProgressInt(payload.mediaBytesWritten)
].join('|'))
const createEmptyTaskPerformance = (): TaskPerformance => ({
stages: {
collect: 0,
@@ -336,6 +407,15 @@ const isTextBatchTask = (task: ExportTask): boolean => (
task.payload.scope === 'content' && task.payload.contentType === 'text'
)
const isImageExportTask = (task: ExportTask): boolean => {
if (task.payload.scope === 'sns') {
return Boolean(task.payload.snsOptions?.exportImages)
}
if (task.payload.scope !== 'content') return false
if (task.payload.contentType === 'image') return true
return Boolean(task.payload.options?.exportImages)
}
const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
if (phase === 'preparing') return 'collect'
if (phase === 'writing') return 'write'
@@ -500,6 +580,35 @@ const getTaskStatusLabel = (task: ExportTask): string => {
return '失败'
}
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
if (status === 'running') return 'running'
if (status === 'pause_requested' || status === 'paused') return 'paused'
if (status === 'cancel_requested' || status === 'canceled') return 'stopped'
if (status === 'completed') return 'success'
return 'error'
}
const parseBackgroundTaskProgress = (progressText?: string): { current: number; total: number; ratio: number | null } => {
const normalized = String(progressText || '').trim()
if (!normalized) {
return { current: 0, total: 0, ratio: null }
}
const match = normalized.match(/(\d+)\s*\/\s*(\d+)/)
if (!match) {
return { current: 0, total: 0, ratio: null }
}
const current = Math.max(0, Math.floor(Number(match[1]) || 0))
const total = Math.max(0, Math.floor(Number(match[2]) || 0))
if (total <= 0) {
return { current, total, ratio: null }
}
return {
current,
total,
ratio: Math.max(0, Math.min(1, current / total))
}
}
const formatAbsoluteDate = (timestamp: number): string => {
const d = new Date(timestamp)
const y = d.getFullYear()
@@ -635,6 +744,11 @@ type ContactsDataSource = 'cache' | 'network' | null
const normalizeAutomationIntervalDays = (value: unknown): number => Math.max(0, Math.floor(Number(value) || 0))
const normalizeAutomationIntervalHours = (value: unknown): number => Math.max(0, Math.min(23, Math.floor(Number(value) || 0)))
const normalizeAutomationFirstTriggerAt = (value: unknown): number => {
const numeric = Math.floor(Number(value) || 0)
if (!Number.isFinite(numeric) || numeric <= 0) return 0
return numeric
}
const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
@@ -644,6 +758,16 @@ const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number
return totalHours * 60 * 60 * 1000
}
const resolveAutomationInitialTriggerAt = (task: ExportAutomationTask): number | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
if (firstTriggerAt > 0) return firstTriggerAt
const createdAt = Math.max(0, Math.floor(Number(task.createdAt || 0)))
if (!createdAt) return null
return createdAt + intervalMs
}
const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
@@ -657,12 +781,60 @@ const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date):
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const nowMs = now.getTime()
const anchorAt = Math.max(
0,
Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)
)
if (nowMs < anchorAt + intervalMs) return null
return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / intervalMs)}`
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
if (lastTriggeredAt > 0) {
if (nowMs < lastTriggeredAt + intervalMs) return null
return `interval:${lastTriggeredAt}:${Math.floor((nowMs - lastTriggeredAt) / intervalMs)}`
}
const initialTriggerAt = resolveAutomationInitialTriggerAt(task)
if (!initialTriggerAt) return null
if (nowMs < initialTriggerAt) return null
return `first:${initialTriggerAt}`
}
const resolveAutomationFirstTriggerSummary = (task: ExportAutomationTask): string => {
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
if (firstTriggerAt <= 0) return '未指定(默认按创建时间+间隔)'
return new Date(firstTriggerAt).toLocaleString('zh-CN')
}
const buildAutomationSchedule = (
intervalDays: number,
intervalHours: number,
firstTriggerAt: number
): ExportAutomationSchedule => ({
type: 'interval',
intervalDays,
intervalHours,
firstTriggerAt: firstTriggerAt > 0 ? firstTriggerAt : undefined
})
const buildAutomationDatePart = (timestamp: number): string => {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return ''
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
const buildAutomationTodayDatePart = (): string => buildAutomationDatePart(Date.now())
const normalizeAutomationDatePart = (value: string): string => {
const text = String(value || '').trim()
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
}
const normalizeAutomationTimePart = (value: string): string => {
const text = String(value || '').trim()
if (!/^\d{2}:\d{2}$/.test(text)) return '00:00'
const [hoursText, minutesText] = text.split(':')
const hours = Math.floor(Number(hoursText))
const minutes = Math.floor(Number(minutesText))
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return '00:00'
const safeHours = Math.min(23, Math.max(0, hours))
const safeMinutes = Math.min(59, Math.max(0, minutes))
return `${`${safeHours}`.padStart(2, '0')}:${`${safeMinutes}`.padStart(2, '0')}`
}
const toDateTimeLocalValue = (timestamp: number): string => {
@@ -803,9 +975,9 @@ const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
const intervalMs = resolveAutomationIntervalMs(task.schedule)
if (intervalMs <= 0) return null
const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0))
if (!anchorAt) return null
return anchorAt + intervalMs
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
if (lastTriggeredAt > 0) return lastTriggeredAt + intervalMs
return resolveAutomationInitialTriggerAt(task)
}
const formatAutomationCurrentState = (
@@ -1589,25 +1761,40 @@ const SectionInfoTooltip = memo(function SectionInfoTooltip({
interface TaskCenterModalProps {
isOpen: boolean
tasks: ExportTask[]
chatBackgroundTasks: BackgroundTaskRecord[]
taskRunningCount: number
taskQueuedCount: number
expandedPerfTaskId: string | null
nowTick: number
onClose: () => void
onTogglePerfTask: (taskId: string) => void
onPauseBackgroundTask: (taskId: string) => void
onResumeBackgroundTask: (taskId: string) => void
onCancelBackgroundTask: (taskId: string) => void
}
const TaskCenterModal = memo(function TaskCenterModal({
isOpen,
tasks,
chatBackgroundTasks,
taskRunningCount,
taskQueuedCount,
expandedPerfTaskId,
nowTick,
onClose,
onTogglePerfTask
onTogglePerfTask,
onPauseBackgroundTask,
onResumeBackgroundTask,
onCancelBackgroundTask
}: TaskCenterModalProps) {
if (!isOpen) return null
const chatActiveTaskCount = chatBackgroundTasks.filter(task => (
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)).length
const totalTaskCount = tasks.length + chatBackgroundTasks.length
return createPortal(
<div
@@ -1624,7 +1811,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
<div className="task-center-modal-header">
<div className="task-center-modal-title">
<h3>任务中心</h3>
<span>进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length}</span>
<span>导出进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 聊天后台 {chatActiveTaskCount} · 总计 {totalTaskCount}</span>
</div>
<button
className="close-icon-btn"
@@ -1636,8 +1823,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
</button>
</div>
<div className="task-center-modal-body">
{tasks.length === 0 ? (
<div className="task-empty">暂无任务。点击会话导出或卡片导出后会在这里创建任务。</div>
{totalTaskCount === 0 ? (
<div className="task-empty">暂无任务。导出任务和聊天页批量语音/图片任务都会显示在这里。</div>
) : (
<div className="task-list">
{tasks.map(task => {
@@ -1705,6 +1892,24 @@ const TaskCenterModal = memo(function TaskCenterModal({
const currentSessionRatio = task.progress.phaseTotal > 0
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
: null
const imageTask = isImageExportTask(task)
const imageTimingElapsedMs = imageTask
? Math.max(0, (
typeof task.finishedAt === 'number'
? task.finishedAt
: nowTick
) - (task.startedAt || task.createdAt))
: 0
const imageTimingAvgMs = imageTask && mediaDoneFiles > 0
? Math.floor(imageTimingElapsedMs / Math.max(1, mediaDoneFiles))
: 0
const imageTimingLabel = imageTask
? (
mediaDoneFiles > 0
? `图片耗时 ${formatDurationMs(imageTimingElapsedMs)} · 平均 ${imageTimingAvgMs}ms/张`
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
)
: ''
return (
<div key={task.id} className={`task-card ${task.status}`}>
<div className="task-main">
@@ -1734,6 +1939,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
</div>
</>
)}
{imageTimingLabel && task.status !== 'queued' && (
<div className="task-perf-summary">
<span>{imageTimingLabel}</span>
</div>
)}
{canShowPerfDetail && stageTotals && (
<div className="task-perf-summary">
<span>累计耗时 {formatDurationMs(stageTotalMs)}</span>
@@ -1802,6 +2012,70 @@ const TaskCenterModal = memo(function TaskCenterModal({
</div>
)
})}
{chatBackgroundTasks.map(task => {
const taskCardClass = resolveBackgroundTaskCardClass(task.status)
const progress = parseBackgroundTaskProgress(task.progressText)
const canPause = task.resumable && task.status === 'running'
const canResume = task.resumable && (task.status === 'paused' || task.status === 'pause_requested')
const canCancel = task.cancelable && (
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)
return (
<div key={task.id} className={`task-card ${taskCardClass}`}>
<div className="task-main">
<div className="task-title">{task.title}</div>
<div className="task-meta">
<span className={`task-status ${taskCardClass}`}>{backgroundTaskStatusLabels[task.status]}</span>
<span>{backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other}</span>
<span>{new Date(task.startedAt).toLocaleString('zh-CN')}</span>
</div>
{progress.ratio !== null && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${progress.ratio * 100}%` }}
/>
</div>
)}
<div className="task-progress-text">
{task.detail || '任务进行中'}
{task.progressText ? ` · ${task.progressText}` : ''}
</div>
</div>
<div className="task-actions">
{canPause && (
<button
className="task-action-btn"
type="button"
onClick={() => onPauseBackgroundTask(task.id)}
>
<Pause size={14} /> 中断
</button>
)}
{canResume && (
<button
className="task-action-btn primary"
type="button"
onClick={() => onResumeBackgroundTask(task.id)}
>
<Play size={14} /> 继续
</button>
)}
<button
className="task-action-btn danger"
type="button"
onClick={() => onCancelBackgroundTask(task.id)}
disabled={!canCancel || task.status === 'cancel_requested'}
>
{task.status === 'cancel_requested' ? '停止中' : '停止'}
</button>
</div>
</div>
)
})}
</div>
)}
</div>
@@ -1903,7 +2177,6 @@ function ExportPage() {
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
const [options, setOptions] = useState<ExportOptions>({
format: 'json',
@@ -1924,8 +2197,7 @@ function ExportPage() {
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
exportConcurrency: 2,
imageDeepSearchOnMiss: true
exportConcurrency: 2
})
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
@@ -2622,7 +2894,7 @@ function ExportPage() {
automationTasksReadyRef.current = false
let isReady = true
try {
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
@@ -2631,7 +2903,6 @@ function ExportPage() {
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(),
configService.getExportDefaultImageDeepSearchOnMiss(),
configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(),
configService.getExportSessionRecordMap(),
@@ -2671,7 +2942,6 @@ function ExportPage() {
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2)
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setAutomationTasks(automationTaskItem?.tasks || [])
automationTasksReadyRef.current = true
@@ -2709,8 +2979,7 @@ function ExportPage() {
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
}))
} catch (error) {
isReady = false
@@ -4491,8 +4760,7 @@ function ExportPage() {
maxFileSizeMb: prev.maxFileSizeMb,
exportVoiceAsText: exportDefaultVoiceAsText,
excelCompactColumns: exportDefaultExcelCompactColumns,
exportConcurrency: exportDefaultConcurrency,
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
exportConcurrency: exportDefaultConcurrency
}
if (payload.scope === 'sns') {
@@ -4527,8 +4795,7 @@ function ExportPage() {
exportDefaultAvatars,
exportDefaultMedia,
exportDefaultVoiceAsText,
exportDefaultConcurrency,
exportDefaultImageDeepSearchOnMiss
exportDefaultConcurrency
])
const closeExportDialog = useCallback(() => {
@@ -4755,7 +5022,6 @@ function ExportPage() {
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
fileNamingMode: exportDefaultFileNamingMode,
sessionLayout,
sessionNameWithTypePrefix,
@@ -4834,6 +5100,7 @@ function ExportPage() {
const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => {
const schedule = task.schedule
const firstTriggerAt = normalizeAutomationFirstTriggerAt(schedule.firstTriggerAt)
const stopAt = Number(task.stopCondition?.endAt || 0)
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
@@ -4854,6 +5121,8 @@ function ExportPage() {
dateRangeConfig: task.template.dateRangeConfig,
intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays),
intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours),
firstTriggerAtEnabled: firstTriggerAt > 0,
firstTriggerAtValue: firstTriggerAt > 0 ? toDateTimeLocalValue(firstTriggerAt) : '',
stopAtEnabled: stopAt > 0,
stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '',
maxRunsEnabled: maxRuns > 0,
@@ -4959,7 +5228,18 @@ function ExportPage() {
window.alert('执行间隔不能为 0,请至少设置天数或小时')
return
}
const schedule: ExportAutomationSchedule = { type: 'interval', intervalDays, intervalHours }
const firstTriggerAtTimestamp = automationTaskDraft.firstTriggerAtEnabled
? parseDateTimeLocalValue(automationTaskDraft.firstTriggerAtValue)
: null
if (automationTaskDraft.firstTriggerAtEnabled && !firstTriggerAtTimestamp) {
window.alert('请填写有效的首次触发时间')
return
}
const schedule = buildAutomationSchedule(
intervalDays,
intervalHours,
firstTriggerAtTimestamp && firstTriggerAtTimestamp > 0 ? firstTriggerAtTimestamp : 0
)
const stopAtTimestamp = automationTaskDraft.stopAtEnabled
? parseDateTimeLocalValue(automationTaskDraft.stopAtValue)
: null
@@ -5146,14 +5426,10 @@ function ExportPage() {
const settledSessionIdsFromProgress = new Set<string>()
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
let queuedProgressPayload: ExportProgress | null = null
let queuedProgressRaf: number | null = null
let queuedProgressSignature = ''
let queuedProgressTimer: number | null = null
const clearQueuedProgress = () => {
if (queuedProgressRaf !== null) {
window.cancelAnimationFrame(queuedProgressRaf)
queuedProgressRaf = null
}
if (queuedProgressTimer !== null) {
window.clearTimeout(queuedProgressTimer)
queuedProgressTimer = null
@@ -5205,6 +5481,7 @@ function ExportPage() {
if (!queuedProgressPayload) return
const payload = queuedProgressPayload
queuedProgressPayload = null
queuedProgressSignature = ''
const now = Date.now()
const currentSessionId = String(payload.currentSessionId || '').trim()
updateTask(next.id, task => {
@@ -5261,77 +5538,71 @@ function ExportPage() {
const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten)
? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0))))
: prevMediaBytesWritten
const nextProgress: TaskProgress = {
current: payload.current,
total: payload.total,
currentName: payload.currentSession || '',
phase: payload.phase,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0,
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
writtenFiles,
mediaDoneFiles,
mediaCacheHitFiles,
mediaCacheMissFiles,
mediaCacheFillFiles,
mediaDedupReuseFiles,
mediaBytesWritten
}
const hasSettledListChanged = !areStringArraysEqual(settledSessionIds, nextSettledSessionIds)
const hasProgressChanged = !areTaskProgressEqual(task.progress, nextProgress)
const hasPerformanceChanged = performance !== task.performance
if (!hasSettledListChanged && !hasProgressChanged && !hasPerformanceChanged) {
return task
}
return {
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phase: payload.phase,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0,
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
writtenFiles,
mediaDoneFiles,
mediaCacheHitFiles,
mediaCacheMissFiles,
mediaCacheFillFiles,
mediaDedupReuseFiles,
mediaBytesWritten
},
settledSessionIds: nextSettledSessionIds,
performance
progress: hasProgressChanged ? nextProgress : task.progress,
settledSessionIds: hasSettledListChanged ? nextSettledSessionIds : settledSessionIds,
performance: hasPerformanceChanged ? performance : task.performance
}
})
}
const queueProgressUpdate = (payload: ExportProgress) => {
const signature = buildProgressPayloadSignature(payload)
if (queuedProgressPayload && signature === queuedProgressSignature) {
return
}
queuedProgressPayload = payload
queuedProgressSignature = signature
if (payload.phase === 'complete') {
clearQueuedProgress()
flushQueuedProgress()
return
}
if (queuedProgressRaf !== null || queuedProgressTimer !== null) return
queuedProgressRaf = window.requestAnimationFrame(() => {
queuedProgressRaf = null
queuedProgressTimer = window.setTimeout(() => {
queuedProgressTimer = null
flushQueuedProgress()
}, 180)
})
if (queuedProgressTimer !== null) return
queuedProgressTimer = window.setTimeout(() => {
queuedProgressTimer = null
flushQueuedProgress()
}, EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS)
}
if (next.payload.scope === 'sns') {
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
updateTask(next.id, task => {
if (task.status !== 'running') return task
return {
...task,
progress: {
current: payload.current || 0,
total: payload.total || 0,
currentName: '',
phase: 'exporting',
phaseLabel: payload.status || '',
phaseProgress: payload.total > 0 ? payload.current : 0,
phaseTotal: payload.total || 0,
exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages,
estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages,
collectedMessages: task.progress.collectedMessages,
writtenFiles: task.progress.writtenFiles,
mediaDoneFiles: task.progress.mediaDoneFiles,
mediaCacheHitFiles: task.progress.mediaCacheHitFiles,
mediaCacheMissFiles: task.progress.mediaCacheMissFiles,
mediaCacheFillFiles: task.progress.mediaCacheFillFiles,
mediaDedupReuseFiles: task.progress.mediaDedupReuseFiles,
mediaBytesWritten: task.progress.mediaBytesWritten
}
}
queueProgressUpdate({
current: Number(payload.current || 0),
total: Number(payload.total || 0),
currentSession: '',
currentSessionId: '',
phase: 'exporting',
phaseLabel: String(payload.status || ''),
phaseProgress: payload.total > 0 ? Number(payload.current || 0) : 0,
phaseTotal: Number(payload.total || 0)
})
})
} else {
@@ -5656,6 +5927,8 @@ function ExportPage() {
dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection),
intervalDays: 1,
intervalHours: 0,
firstTriggerAtEnabled: false,
firstTriggerAtValue: '',
stopAtEnabled: false,
stopAtValue: '',
maxRunsEnabled: false,
@@ -5691,8 +5964,6 @@ function ExportPage() {
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency)
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
}
const openSingleExport = useCallback((session: SessionRow) => {
@@ -7336,11 +7607,23 @@ function ExportPage() {
const handleCancelBackgroundTask = useCallback((taskId: string) => {
requestCancelBackgroundTask(taskId)
}, [])
const handlePauseBackgroundTask = useCallback((taskId: string) => {
requestPauseBackgroundTask(taskId)
}, [])
const handleResumeBackgroundTask = useCallback((taskId: string) => {
requestResumeBackgroundTask(taskId)
}, [])
const handleCancelAllNonExportTasks = useCallback(() => {
requestCancelBackgroundTasks(task => (
task.sourcePage !== 'export' &&
task.sourcePage !== 'chat' &&
task.cancelable &&
(task.status === 'running' || task.status === 'cancel_requested')
(
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)
))
}, [])
@@ -7393,14 +7676,6 @@ function ExportPage() {
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
const shouldShowMediaSection = !isContentScopeDialog
const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
isSessionScopeDialog ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
(isSessionScopeDialog && options.exportImages) ||
(isContentScopeDialog && exportDialog.contentType === 'image')
)
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
const activeDialogFormatLabel = exportDialog.scope === 'sns'
@@ -7496,7 +7771,18 @@ function ExportPage() {
const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
const chatBackgroundTasks = useMemo(() => (
backgroundTasks.filter(task => task.sourcePage === 'chat')
), [backgroundTasks])
const chatBackgroundActiveTaskCount = useMemo(() => (
chatBackgroundTasks.filter(task => (
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)).length
), [chatBackgroundTasks])
const taskCenterAlertCount = taskRunningCount + taskQueuedCount + chatBackgroundActiveTaskCount
const hasFilteredContacts = filteredContacts.length > 0
const optionalMetricColumnCount = (shouldShowSnsColumn ? 1 : 0) + (shouldShowMutualFriendsColumn ? 1 : 0)
const contactsMetricColumnCount = 4 + optionalMetricColumnCount
@@ -7511,15 +7797,25 @@ function ExportPage() {
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
}), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
const nonExportBackgroundTasks = useMemo(() => (
backgroundTasks.filter(task => task.sourcePage !== 'export')
backgroundTasks.filter(task => task.sourcePage !== 'export' && task.sourcePage !== 'chat')
), [backgroundTasks])
const runningNonExportTaskCount = useMemo(() => (
nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length
nonExportBackgroundTasks.filter(task => (
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)).length
), [nonExportBackgroundTasks])
const cancelableNonExportTaskCount = useMemo(() => (
nonExportBackgroundTasks.filter(task => (
task.cancelable &&
(task.status === 'running' || task.status === 'cancel_requested')
(
task.status === 'running' ||
task.status === 'pause_requested' ||
task.status === 'paused' ||
task.status === 'cancel_requested'
)
)).length
), [nonExportBackgroundTasks])
const nonExportBackgroundTasksUpdatedAt = useMemo(() => (
@@ -8139,12 +8435,16 @@ function ExportPage() {
<TaskCenterModal
isOpen={isTaskCenterOpen}
tasks={tasks}
chatBackgroundTasks={chatBackgroundTasks}
taskRunningCount={taskRunningCount}
taskQueuedCount={taskQueuedCount}
expandedPerfTaskId={expandedPerfTaskId}
nowTick={nowTick}
onClose={closeTaskCenter}
onTogglePerfTask={toggleTaskPerfDetail}
onPauseBackgroundTask={handlePauseBackgroundTask}
onResumeBackgroundTask={handleResumeBackgroundTask}
onCancelBackgroundTask={handleCancelBackgroundTask}
/>
{isAutomationModalOpen && createPortal(
@@ -8220,6 +8520,7 @@ function ExportPage() {
{queueState === 'queued' && <span className="automation-task-status queued">排队中</span>}
</div>
<p>{formatAutomationScheduleLabel(task.schedule)}</p>
<p>首次触发:{resolveAutomationFirstTriggerSummary(task)}</p>
<p>时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}</p>
<p>会话范围:{task.sessionIds.length} 个</p>
<p>导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}</p>
@@ -8333,6 +8634,52 @@ function ExportPage() {
</label>
</div>
<div className="automation-form-field">
<span>首次触发时间(可选)</span>
<label className="automation-inline-check">
<input
type="checkbox"
checked={automationTaskDraft.firstTriggerAtEnabled}
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
...prev,
firstTriggerAtEnabled: event.target.checked
} : prev)}
/>
指定第一次触发时间
</label>
{automationTaskDraft.firstTriggerAtEnabled && (
<div className="automation-first-trigger-picker">
<input
type="date"
className="automation-stopat-date"
value={automationTaskDraft.firstTriggerAtValue ? automationTaskDraft.firstTriggerAtValue.slice(0, 10) : ''}
onChange={(event) => {
const datePart = normalizeAutomationDatePart(event.target.value)
const timePart = normalizeAutomationTimePart(automationTaskDraft.firstTriggerAtValue?.slice(11) || '00:00')
setAutomationTaskDraft((prev) => prev ? {
...prev,
firstTriggerAtValue: datePart ? `${datePart}T${timePart}` : ''
} : prev)
}}
/>
<input
type="time"
className="automation-stopat-time"
value={automationTaskDraft.firstTriggerAtValue ? normalizeAutomationTimePart(automationTaskDraft.firstTriggerAtValue.slice(11)) : '00:00'}
onChange={(event) => {
const timePart = normalizeAutomationTimePart(event.target.value)
const datePart = normalizeAutomationDatePart(automationTaskDraft.firstTriggerAtValue?.slice(0, 10))
|| buildAutomationTodayDatePart()
setAutomationTaskDraft((prev) => prev ? {
...prev,
firstTriggerAtValue: `${datePart}T${timePart}`
} : prev)
}}
/>
</div>
)}
</div>
<div className="automation-form-field">
<span>导出时间范围(按触发时间动态计算)</span>
<div className="automation-segment-row">
@@ -8473,7 +8820,11 @@ function ExportPage() {
</label>
<div className="automation-draft-summary">
会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 首次:{
automationTaskDraft.firstTriggerAtEnabled
? (automationTaskDraft.firstTriggerAtValue ? automationTaskDraft.firstTriggerAtValue.replace('T', ' ') : '未设置')
: '默认按创建时间+间隔'
} · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
</div>
</div>
<div className="automation-editor-actions">
@@ -8946,7 +9297,12 @@ function ExportPage() {
type="button"
className="session-load-detail-task-stop-btn"
onClick={() => handleCancelBackgroundTask(task.id)}
disabled={!task.cancelable || (task.status !== 'running' && task.status !== 'cancel_requested')}
disabled={!task.cancelable || (
task.status !== 'running' &&
task.status !== 'pause_requested' &&
task.status !== 'paused' &&
task.status !== 'cancel_requested'
)}
>
停止
</button>
@@ -9710,30 +10066,6 @@ function ExportPage() {
</div>
)}
{shouldRenderImageDeepSearchToggle && (
<div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}>
<div className="dialog-collapse-inner">
<div className="dialog-section">
<div className="dialog-switch-row">
<div className="dialog-switch-copy">
<h4>缺图时深度搜索</h4>
<div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div>
</div>
<button
type="button"
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
aria-pressed={options.imageDeepSearchOnMiss}
aria-label="切换缺图时深度搜索"
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
>
<span className="dialog-switch-thumb" />
</button>
</div>
</div>
</div>
</div>
)}
{isSessionScopeDialog && (
<div className="dialog-section">
<div className="dialog-switch-row">
+138 -51
View File
@@ -44,6 +44,7 @@ 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 BATCH_IMAGE_DECRYPT_CONCURRENCY = 8
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
const { className = '', ...rest } = props
@@ -71,6 +72,20 @@ function getRangeTimestampEnd(date: string): number | undefined {
return Number.isFinite(n) ? n : undefined
}
function normalizeMediaToken(value?: string): string {
return String(value || '').trim().toLowerCase()
}
function getSafeImageDatName(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): string {
const datName = normalizeMediaToken(item.imageDatName)
if (!datName) return ''
return datName
}
function hasImageLocator(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): boolean {
return Boolean(normalizeMediaToken(item.imageMd5) || getSafeImageDatName(item))
}
function getItemKey(item: MediaStreamItem): string {
const sessionId = String(item.sessionId || '').trim().toLowerCase()
const localId = Number(item.localId || 0)
@@ -84,7 +99,7 @@ function getItemKey(item: MediaStreamItem): string {
const mediaId = String(
item.mediaType === 'video'
? (item.videoMd5 || '')
: (item.imageMd5 || item.imageDatName || '')
: (item.imageMd5 || getSafeImageDatName(item) || '')
).trim().toLowerCase()
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
}
@@ -658,19 +673,20 @@ function ResourcesPage() {
const to = Math.min(displayItems.length - 1, end)
if (to < from) return
const now = Date.now()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
const itemKeys: string[] = []
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
if (!item || item.mediaType !== 'image') continue
const itemKey = getItemKey(item)
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
if (!item.imageMd5 && !item.imageDatName) continue
if (!hasImageLocator(item)) continue
if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue
payloads.push({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined
})
itemKeys.push(itemKey)
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
@@ -686,7 +702,10 @@ function ResourcesPage() {
try {
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
disableUpdateCheck: true,
allowCacheIndex: false
allowCacheIndex: true,
preferFilePath: true,
hardlinkOnly: true,
suppressEvents: true
})
const rows = Array.isArray(result?.rows) ? result.rows : []
const pathPatch: Record<string, string> = {}
@@ -733,30 +752,31 @@ function ResourcesPage() {
if (to < from) return
const now = Date.now()
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
const dedup = new Set<string>()
for (let i = from; i <= to; i += 1) {
const item = displayItems[i]
if (!item || item.mediaType !== 'image') continue
const itemKey = getItemKey(item)
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
if (!item.imageMd5 && !item.imageDatName) continue
if (!hasImageLocator(item)) continue
if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue
const dedupKey = `${item.sessionId || ''}|${item.imageMd5 || ''}|${item.imageDatName || ''}`
const dedupKey = `${item.sessionId || ''}|${normalizeMediaToken(item.imageMd5)}|${getSafeImageDatName(item)}`
if (dedup.has(dedupKey)) continue
dedup.add(dedupKey)
imagePreloadUntilRef.current[itemKey] = now + 12000
payloads.push({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined
})
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
}
if (payloads.length === 0) return
void window.electronAPI.image.preload(payloads, {
allowDecrypt: false,
allowCacheIndex: false
allowCacheIndex: true
})
}, [displayItems])
@@ -954,11 +974,14 @@ function ResourcesPage() {
}, '批量删除确认')
}, [batchBusy, selectedItems, showAlert, showConfirm])
const decryptImage = useCallback(async (item: MediaStreamItem): Promise<string | undefined> => {
const decryptImage = useCallback(async (
item: MediaStreamItem,
options?: { allowCacheIndex?: boolean }
): Promise<string | undefined> => {
if (item.mediaType !== 'image') return
const key = getItemKey(item)
if (!item.imageMd5 && !item.imageDatName) {
if (!hasImageLocator(item)) {
showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密')
return
}
@@ -972,12 +995,21 @@ function ResourcesPage() {
try {
const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined,
force: true
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined,
force: true,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: options?.allowCacheIndex ?? true,
suppressEvents: true
})
if (!result?.success) {
showAlert(`解密失败:${result?.error || '未知错误'}`, '解密失败')
if (result?.failureKind === 'decrypt_failed') {
showAlert(`解密失败:${result?.error || '解密后不是有效图片'}`, '解密失败')
} else {
showAlert(`本地无数据:${result?.error || '未找到原始 DAT 文件'}`, '未找到本地数据')
}
return undefined
}
@@ -991,8 +1023,13 @@ function ResourcesPage() {
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: true,
suppressEvents: true
})
if (resolved?.success && resolved.localPath) {
const localPath = resolved.localPath
@@ -1007,7 +1044,7 @@ function ResourcesPage() {
setActionMessage('图片解密完成')
return undefined
} catch (e) {
showAlert(`解密失败:${String(e)}`, '解密失败')
showAlert(`本地无数据:${String(e)}`, '未找到本地数据')
return undefined
} finally {
setDecryptingKeys((prev) => {
@@ -1027,8 +1064,13 @@ function ResourcesPage() {
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: true,
suppressEvents: true
})
if (resolved?.success && resolved.localPath) {
localPath = resolved.localPath
@@ -1046,8 +1088,13 @@ function ResourcesPage() {
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: true,
suppressEvents: true
})
if (resolved?.success && resolved.localPath) {
localPath = resolved.localPath
@@ -1077,7 +1124,8 @@ function ResourcesPage() {
setBatchBusy(true)
let success = 0
let failed = 0
let notFound = 0
let decryptFailed = 0
const previewPatch: Record<string, string> = {}
const updatePatch: Record<string, boolean> = {}
const taskId = registerBackgroundTask({
@@ -1105,32 +1153,71 @@ function ResourcesPage() {
lastProgressBucket = bucket
lastProgressUpdateAt = now
}
const hardlinkMd5Set = new Set<string>()
for (const item of imageItems) {
if (!item.imageMd5 && !item.imageDatName) {
failed += 1
completed += 1
updateTaskProgress()
if (!hasImageLocator(item)) continue
const imageMd5 = normalizeMediaToken(item.imageMd5)
if (imageMd5) {
hardlinkMd5Set.add(imageMd5)
continue
}
const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId,
imageMd5: item.imageMd5 || undefined,
imageDatName: item.imageDatName || undefined,
force: true
})
if (!result?.success) {
failed += 1
} else {
success += 1
if (result.localPath) {
const key = getItemKey(item)
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
const imageDatName = getSafeImageDatName(item)
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
hardlinkMd5Set.add(imageDatName)
}
}
if (hardlinkMd5Set.size > 0) {
try {
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set))
} catch {
// ignore preload failures and continue decrypt
}
}
const concurrency = Math.max(1, Math.min(BATCH_IMAGE_DECRYPT_CONCURRENCY, imageItems.length))
let cursor = 0
const worker = async () => {
while (true) {
const index = cursor
cursor += 1
if (index >= imageItems.length) return
const item = imageItems[index]
try {
if (!hasImageLocator(item)) {
notFound += 1
continue
}
const result = await window.electronAPI.image.decrypt({
sessionId: item.sessionId,
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
imageDatName: getSafeImageDatName(item) || undefined,
createTime: Number(item.createTime || 0) || undefined,
force: true,
preferFilePath: true,
hardlinkOnly: true,
allowCacheIndex: true,
suppressEvents: true
})
if (!result?.success) {
if (result?.failureKind === 'decrypt_failed') decryptFailed += 1
else notFound += 1
} else {
success += 1
if (result.localPath) {
const key = getItemKey(item)
previewPatch[key] = result.localPath
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
}
}
} catch {
notFound += 1
} finally {
completed += 1
updateTaskProgress()
}
}
completed += 1
updateTaskProgress()
}
await Promise.all(Array.from({ length: concurrency }, () => worker()))
updateTaskProgress(true)
if (Object.keys(previewPatch).length > 0) {
@@ -1139,11 +1226,11 @@ function ResourcesPage() {
if (Object.keys(updatePatch).length > 0) {
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
}
setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
progressText: `成功 ${success} / 失败 ${failed}`
setActionMessage(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`)
showAlert(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`, '批量解密完成')
finishBackgroundTask(taskId, decryptFailed > 0 ? 'failed' : 'completed', {
detail: `资源页图片批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`,
progressText: `成功 ${success} / 未找到 ${notFound} / 解密失败 ${decryptFailed}`
})
} catch (e) {
finishBackgroundTask(taskId, 'failed', {
+16 -33
View File
@@ -56,43 +56,28 @@ const normalizeDbKeyStatusMessage = (message: string): string => {
return message
}
const isDbKeyReadyMessage = (message: string): boolean => (
message.includes('现在可以登录')
|| message.includes('Hook安装成功')
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
)
const isDbKeyReadyMessage = (message: string): boolean => {
if (isWindows) {
return message.includes('现在可以登录')
|| message.includes('Hook安装成功')
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
}
return message.includes('现在可以登录')
}
const pickWxidByAnchorTime = (
wxids: Array<{ wxid: string; modifiedTime: number }>,
anchorTime?: number
const pickLatestWxid = (
wxids: Array<{ wxid: string; modifiedTime: number }>
): string => {
if (!Array.isArray(wxids) || wxids.length === 0) return ''
const fallbackWxid = wxids[0]?.wxid || ''
if (!anchorTime || !Number.isFinite(anchorTime)) return fallbackWxid
const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0)
if (valid.length === 0) return fallbackWxid
const anchor = Number(anchorTime)
const nearWindowMs = 10 * 60 * 1000
const near = valid
.filter(item => Math.abs(item.modifiedTime - anchor) <= nearWindowMs)
.sort((a, b) => {
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
if (diffGap !== 0) return diffGap
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
if (near.length > 0) return near[0].wxid
const closest = valid.sort((a, b) => {
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
if (diffGap !== 0) return diffGap
const latest = [...valid].sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
return closest[0]?.wxid || fallbackWxid
return latest[0]?.wxid || fallbackWxid
}
function WelcomePage({ standalone = false }: WelcomePageProps) {
@@ -434,7 +419,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
}
const handleScanWxid = async (silent = false, anchorTime?: number) => {
const handleScanWxid = async (silent = false) => {
if (!dbPath) {
if (!silent) setError('请先选择数据库目录')
return
@@ -446,9 +431,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
setWxidOptions(wxids)
if (wxids.length > 0) {
// 密钥成功后使用成功时刻作为锚点,自动选择最接近该时刻的活跃账号;
// 其余场景保持“时间最新”优先。
const selectedWxid = pickWxidByAnchorTime(wxids, anchorTime)
// 自动获取密钥后,始终优先选择最近活跃(modifiedTime 最新)的账号。
const selectedWxid = pickLatestWxid(wxids)
setWxid(selectedWxid || wxids[0].wxid)
if (!silent) setError('')
} else {
@@ -501,8 +485,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setHasReacquiredDbKey(true)
setDbKeyStatus('密钥获取成功')
setError('')
const keySuccessAt = Date.now()
await handleScanWxid(true, keySuccessAt)
await handleScanWxid(true)
} else {
if (isAddAccountMode) {
setHasReacquiredDbKey(false)
+67 -2
View File
@@ -9,10 +9,12 @@ type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
const tasks = new Map<string, BackgroundTaskRecord>()
const cancelHandlers = new Map<string, () => void | Promise<void>>()
const pauseHandlers = new Map<string, () => void | Promise<void>>()
const resumeHandlers = new Map<string, () => void | Promise<void>>()
const listeners = new Set<BackgroundTaskListener>()
let taskSequence = 0
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'cancel_requested'])
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'pause_requested', 'paused', 'cancel_requested'])
const MAX_SETTLED_TASKS = 24
const buildTaskId = (): string => {
@@ -34,6 +36,9 @@ const pruneSettledTasks = () => {
for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
tasks.delete(staleTask.id)
cancelHandlers.delete(staleTask.id)
pauseHandlers.delete(staleTask.id)
resumeHandlers.delete(staleTask.id)
}
}
@@ -64,7 +69,9 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
detail: input.detail,
progressText: input.progressText,
cancelable: input.cancelable !== false,
resumable: input.resumable === true,
cancelRequested: false,
pauseRequested: false,
status: 'running',
startedAt: now,
updatedAt: now
@@ -72,6 +79,12 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
if (input.onCancel) {
cancelHandlers.set(taskId, input.onCancel)
}
if (input.onPause) {
pauseHandlers.set(taskId, input.onPause)
}
if (input.onResume) {
resumeHandlers.set(taskId, input.onResume)
}
pruneSettledTasks()
notifyListeners()
return taskId
@@ -87,6 +100,9 @@ export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate
...patch,
status: nextStatus,
updatedAt: nextUpdatedAt,
pauseRequested: nextStatus === 'paused' || nextStatus === 'pause_requested'
? true
: (nextStatus === 'running' ? false : existing.pauseRequested),
finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
})
pruneSettledTasks()
@@ -107,9 +123,12 @@ export const finishBackgroundTask = (
status,
updatedAt: now,
finishedAt: now,
cancelRequested: status === 'canceled' ? true : existing.cancelRequested
cancelRequested: status === 'canceled' ? true : existing.cancelRequested,
pauseRequested: false
})
cancelHandlers.delete(taskId)
pauseHandlers.delete(taskId)
resumeHandlers.delete(taskId)
pruneSettledTasks()
notifyListeners()
}
@@ -121,6 +140,7 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => {
...existing,
status: 'cancel_requested',
cancelRequested: true,
pauseRequested: false,
detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
updatedAt: Date.now()
})
@@ -132,6 +152,46 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => {
return true
}
export const requestPauseBackgroundTask = (taskId: string): boolean => {
const existing = tasks.get(taskId)
if (!existing || !existing.resumable) return false
if (existing.status !== 'running' && existing.status !== 'pause_requested') return false
tasks.set(taskId, {
...existing,
status: 'pause_requested',
pauseRequested: true,
detail: existing.detail || '中断请求已发出,当前处理完成后会暂停',
updatedAt: Date.now()
})
const pauseHandler = pauseHandlers.get(taskId)
if (pauseHandler) {
void Promise.resolve(pauseHandler()).catch(() => {})
}
notifyListeners()
return true
}
export const requestResumeBackgroundTask = (taskId: string): boolean => {
const existing = tasks.get(taskId)
if (!existing || !existing.resumable) return false
if (existing.status !== 'paused' && existing.status !== 'pause_requested') return false
tasks.set(taskId, {
...existing,
status: 'running',
cancelRequested: false,
pauseRequested: false,
detail: existing.detail || '任务已继续',
updatedAt: Date.now(),
finishedAt: undefined
})
const resumeHandler = resumeHandlers.get(taskId)
if (resumeHandler) {
void Promise.resolve(resumeHandler()).catch(() => {})
}
notifyListeners()
return true
}
export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => {
let canceledCount = 0
for (const task of tasks.values()) {
@@ -147,3 +207,8 @@ export const isBackgroundTaskCancelRequested = (taskId: string): boolean => {
const task = tasks.get(taskId)
return Boolean(task?.cancelRequested)
}
export const isBackgroundTaskPauseRequested = (taskId: string): boolean => {
const task = tasks.get(taskId)
return Boolean(task?.pauseRequested)
}
+7 -14
View File
@@ -37,7 +37,6 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
@@ -559,18 +558,6 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
// 获取缺图时是否深度搜索(默认导出行为)
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
if (typeof value === 'boolean') return value
return null
}
// 设置缺图时是否深度搜索(默认导出行为)
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
}
export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
@@ -713,11 +700,17 @@ const normalizeAutomationTask = (raw: unknown): ExportAutomationTask | null => {
if (scheduleType === 'interval') {
const rawDays = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalDays, 0))
const rawHours = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalHours, 0))
const rawFirstTriggerAt = Math.max(0, normalizeAutomationNumeric(scheduleObj.firstTriggerAt, 0))
const totalHours = (rawDays * 24) + rawHours
if (totalHours <= 0) return null
const intervalDays = Math.floor(totalHours / 24)
const intervalHours = totalHours % 24
schedule = { type: 'interval', intervalDays, intervalHours }
schedule = {
type: 'interval',
intervalDays,
intervalHours,
firstTriggerAt: rawFirstTriggerAt > 0 ? rawFirstTriggerAt : undefined
}
}
if (!schedule) return null
+51 -12
View File
@@ -4,7 +4,21 @@ import {
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
interface BatchDecryptTaskControls {
cancelable?: boolean
resumable?: boolean
onCancel?: () => void | Promise<void>
onPause?: () => void | Promise<void>
onResume?: () => void | Promise<void>
}
interface BatchDecryptFinishOptions {
status?: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>
detail?: string
progressText?: string
}
export interface BatchImageDecryptState {
isBatchDecrypting: boolean
@@ -16,9 +30,15 @@ export interface BatchImageDecryptState {
sessionName: string
taskId: string | null
startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void
startDecrypt: (
total: number,
sessionName: string,
sourcePage?: BackgroundTaskSourcePage,
controls?: BatchDecryptTaskControls
) => void
updateProgress: (current: number, total: number) => void
finishDecrypt: (success: number, fail: number) => void
setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void
finishDecrypt: (success: number, fail: number, options?: BatchDecryptFinishOptions) => void
setShowToast: (show: boolean) => void
setShowResultToast: (show: boolean) => void
reset: () => void
@@ -53,7 +73,7 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
sessionName: '',
taskId: null,
startDecrypt: (total, sessionName, sourcePage = 'chat') => {
startDecrypt: (total, sessionName, sourcePage = 'chat', controls) => {
const previousTaskId = get().taskId
if (previousTaskId) {
taskProgressUpdateMeta.delete(previousTaskId)
@@ -73,7 +93,11 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
title,
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
cancelable: false
cancelable: controls?.cancelable !== false,
resumable: controls?.resumable === true,
onCancel: controls?.onCancel,
onPause: controls?.onPause,
onResume: controls?.onResume
})
taskProgressUpdateMeta.set(taskId, {
lastAt: Date.now(),
@@ -97,6 +121,7 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
const previousProgress = get().progress
const normalizedProgress = clampProgress(current, total)
const taskId = get().taskId
let shouldCommitUi = true
if (taskId) {
const now = Date.now()
const meta = taskProgressUpdateMeta.get(taskId)
@@ -105,7 +130,9 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
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) {
const shouldPublish = crossedBucket || intervalReached || isFinal
shouldCommitUi = shouldPublish
if (shouldPublish) {
updateBackgroundTask(taskId, {
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
@@ -117,26 +144,38 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
})
}
}
if (
if (shouldCommitUi && (
previousProgress.current !== normalizedProgress.current ||
previousProgress.total !== normalizedProgress.total
) {
)) {
set({
progress: normalizedProgress
})
}
},
finishDecrypt: (success, fail) => {
setTaskStatus: (detail, progressText, status) => {
const taskId = get().taskId
if (!taskId) return
const normalizedDetail = String(detail || '').trim()
if (!normalizedDetail) return
updateBackgroundTask(taskId, {
detail: normalizedDetail,
progressText,
status
})
},
finishDecrypt: (success, fail, options) => {
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'
const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed')
finishBackgroundTask(taskId, status, {
detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
detail: options?.detail || `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
})
}
+190 -33
View File
@@ -1,7 +1,27 @@
import { create } from 'zustand'
import {
finishBackgroundTask,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
export type BatchVoiceTaskType = 'transcribe' | 'decrypt'
interface BatchVoiceTaskControls {
cancelable?: boolean
resumable?: boolean
onCancel?: () => void | Promise<void>
onPause?: () => void | Promise<void>
onResume?: () => void | Promise<void>
}
interface BatchVoiceTaskFinishOptions {
status?: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>
detail?: string
progressText?: string
}
export interface BatchTranscribeState {
/** 是否正在批量转写 */
isBatchTranscribing: boolean
@@ -18,17 +38,44 @@ export interface BatchTranscribeState {
/** 当前转写的会话名 */
startTime: number
sessionName: string
taskId: string | null
// Actions
startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void
startTranscribe: (
total: number,
sessionName: string,
taskType?: BatchVoiceTaskType,
sourcePage?: BackgroundTaskSourcePage,
controls?: BatchVoiceTaskControls
) => void
updateProgress: (current: number, total: number) => void
finishTranscribe: (success: number, fail: number) => void
setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void
finishTranscribe: (success: number, fail: number, options?: BatchVoiceTaskFinishOptions) => void
setShowToast: (show: boolean) => void
setShowResult: (show: boolean) => void
reset: () => void
}
export const useBatchTranscribeStore = create<BatchTranscribeState>((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 useBatchTranscribeStore = create<BatchTranscribeState>((set, get) => ({
isBatchTranscribing: false,
taskType: 'transcribe',
progress: { current: 0, total: 0 },
@@ -37,41 +84,151 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
result: { success: 0, fail: 0 },
sessionName: '',
startTime: 0,
taskId: null,
startTranscribe: (total, sessionName, taskType = 'transcribe') => set({
isBatchTranscribing: true,
taskType,
showToast: true,
progress: { current: 0, total },
showResult: false,
result: { success: 0, fail: 0 },
sessionName,
startTime: Date.now()
}),
startTranscribe: (total, sessionName, taskType = 'transcribe', sourcePage = 'chat', controls) => {
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 taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写'
const title = normalizedSessionName
? `${taskLabel}(${normalizedSessionName})`
: taskLabel
const taskId = registerBackgroundTask({
sourcePage,
title,
detail: `正在准备${taskType === 'decrypt' ? '语音解密' : '语音转写'}任务...`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
cancelable: controls?.cancelable !== false,
resumable: controls?.resumable === true,
onCancel: controls?.onCancel,
onPause: controls?.onPause,
onResume: controls?.onResume
})
taskProgressUpdateMeta.set(taskId, {
lastAt: Date.now(),
lastBucket: 0,
step: calcProgressStep(normalizedProgress.total)
})
finishTranscribe: (success, fail) => set({
isBatchTranscribing: false,
showToast: false,
showResult: true,
result: { success, fail },
startTime: 0
}),
set({
isBatchTranscribing: true,
taskType,
showToast: false,
progress: normalizedProgress,
showResult: false,
result: { success: 0, fail: 0 },
sessionName: normalizedSessionName,
startTime: Date.now(),
taskId
})
},
updateProgress: (current, total) => {
const previousProgress = get().progress
const normalizedProgress = clampProgress(current, total)
const taskId = get().taskId
let shouldCommitUi = true
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
const shouldPublish = crossedBucket || intervalReached || isFinal
shouldCommitUi = shouldPublish
if (shouldPublish) {
const taskVerb = get().taskType === 'decrypt' ? '解密语音' : '转写语音'
updateBackgroundTask(taskId, {
detail: `正在${taskVerb}(${normalizedProgress.current}/${normalizedProgress.total})`,
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
})
taskProgressUpdateMeta.set(taskId, {
lastAt: now,
lastBucket: bucket,
step
})
}
}
if (shouldCommitUi && (
previousProgress.current !== normalizedProgress.current ||
previousProgress.total !== normalizedProgress.total
)) {
set({
progress: normalizedProgress
})
}
},
setTaskStatus: (detail, progressText, status) => {
const taskId = get().taskId
if (!taskId) return
const normalizedDetail = String(detail || '').trim()
if (!normalizedDetail) return
updateBackgroundTask(taskId, {
detail: normalizedDetail,
progressText,
status
})
},
finishTranscribe: (success, fail, options) => {
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
const taskType = get().taskType
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed')
const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写'
finishBackgroundTask(taskId, status, {
detail: options?.detail || `${taskLabel}完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
})
}
set({
isBatchTranscribing: false,
showToast: false,
showResult: false,
result: { success: normalizedSuccess, fail: normalizedFail },
startTime: 0,
taskId: null
})
},
setShowToast: (show) => set({ showToast: show }),
setShowResult: (show) => set({ showResult: show }),
reset: () => set({
isBatchTranscribing: false,
taskType: 'transcribe',
progress: { current: 0, total: 0 },
showToast: false,
showResult: false,
result: { success: 0, fail: 0 },
sessionName: '',
startTime: 0
})
reset: () => {
const taskId = get().taskId
if (taskId) {
taskProgressUpdateMeta.delete(taskId)
finishBackgroundTask(taskId, 'canceled', {
detail: '语音批量任务已重置',
progressText: '已停止'
})
}
set({
isBatchTranscribing: false,
taskType: 'transcribe',
progress: { current: 0, total: 0 },
showToast: false,
showResult: false,
result: { success: 0, fail: 0 },
sessionName: '',
startTime: 0,
taskId: null
})
}
}))
+7
View File
@@ -9,6 +9,8 @@ export type BackgroundTaskSourcePage =
export type BackgroundTaskStatus =
| 'running'
| 'pause_requested'
| 'paused'
| 'cancel_requested'
| 'completed'
| 'failed'
@@ -21,7 +23,9 @@ export interface BackgroundTaskRecord {
detail?: string
progressText?: string
cancelable: boolean
resumable: boolean
cancelRequested: boolean
pauseRequested: boolean
status: BackgroundTaskStatus
startedAt: number
updatedAt: number
@@ -34,7 +38,10 @@ export interface BackgroundTaskInput {
detail?: string
progressText?: string
cancelable?: boolean
resumable?: boolean
onCancel?: () => void | Promise<void>
onPause?: () => void | Promise<void>
onResume?: () => void | Promise<void>
}
export interface BackgroundTaskUpdate {
+22 -7
View File
@@ -505,26 +505,42 @@ export interface ElectronAPI {
}
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
decrypt: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
force?: boolean
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
suppressEvents?: boolean
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => Promise<{
success: boolean
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
error?: string
}>
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => Promise<boolean>
preloadHardlinkMd5s: (md5List: string[]) => 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
onDecryptProgress: (callback: (payload: {
@@ -1135,7 +1151,6 @@ export interface ExportOptions {
sessionNameWithTypePrefix?: boolean
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency?: number
imageDeepSearchOnMiss?: boolean
}
export interface ExportProgress {
+1
View File
@@ -8,6 +8,7 @@ export type ExportAutomationSchedule =
type: 'interval'
intervalDays: number
intervalHours: number
firstTriggerAt?: number
}
export interface ExportAutomationCondition {