diff --git a/.github/scripts/release-utils.sh b/.github/scripts/release-utils.sh new file mode 100644 index 0000000..1ebfc5e --- /dev/null +++ b/.github/scripts/release-utils.sh @@ -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 +} diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index 428aa14..e47ffbd 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -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}' diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 52aa2d4..aba5cbb 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -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}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b17ef49..7092e50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 920d437..25fdeab 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,4 @@ pnpm-lock.yaml wechat-research-site .codex weflow-web-offical -Insight +/Wedecrypt \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index dc2e1d7..7e4d381 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -372,6 +372,7 @@ if (process.platform === 'darwin') { let mainWindowReady = false let shouldShowMain = true let isAppQuitting = false +let shutdownPromise: Promise | null = null let tray: Tray | null = null let isClosePromptVisible = false const chatHistoryPayloadStore = new Map() @@ -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>() + + 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 => { + 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', () => { diff --git a/electron/preload.ts b/electron/preload.ts index 9ebc1b4..09126a7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e6da68f..5d58177 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -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), diff --git a/electron/services/cloudControlService.ts b/electron/services/cloudControlService.ts index 39b3345..198c5df 100644 --- a/electron/services/cloudControlService.ts +++ b/electron/services/cloudControlService.ts @@ -218,7 +218,7 @@ class CloudControlService { this.pages.add(pageName) } - stop() { + async stop(): Promise { 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() { diff --git a/electron/services/config.ts b/electron/services/config.ts index 7e0b5a5..a1066f6 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -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 钥匙串弹窗 diff --git a/electron/services/dbPathService.ts b/electron/services/dbPathService.ts index 592c9f9..87fe017 100644 --- a/electron/services/dbPathService.ts +++ b/electron/services/dbPathService.ts @@ -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 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 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) { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 2717718..fe44d51 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -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 } ): Promise { @@ -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, - imageDeepSearchOnMiss = true + dirCache?: Set ): Promise { 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) diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index e7a7f83..f353434 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -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() + 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}`) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 84c908c..9f300c5 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -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() private pending = new Map>() - private readonly defaultV1AesKey = 'cfcd208495d565ef' - private cacheIndexed = false - private cacheIndexing: Promise | null = null private updateFlags = new Map() + private nativeLogged = false + private datNameScanMissAt = new Map() + private readonly datNameScanMissTtlMs = 1200 + private readonly accountDirCache = new Map() + private cacheRootPath: string | null = null + private readonly ensuredDirs = new Set() + + 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): 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 { - 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) - } - } else { - this.updateFlags.delete(key) + 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) + } + 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 { - 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 { 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 { - 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 { 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 { + 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() + 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() + 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, + 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 { 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 { - 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 { - 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 { - 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 { - 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 { - 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 - 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 { - 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 { - const keyCount: Map = 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 { - 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)) { diff --git a/electron/services/imagePreloadService.ts b/electron/services/imagePreloadService.ts index 05a772a..dacee88 100644 --- a/electron/services/imagePreloadService.ts +++ b/electron/services/imagePreloadService.ts @@ -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 diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index 9900ec3..53fefa4 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -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]), { diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index ca7e057..3870ac0 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -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 diff --git a/electron/services/nativeImageDecrypt.ts b/electron/services/nativeImageDecrypt.ts new file mode 100644 index 0000000..bcaacb7 --- /dev/null +++ b/electron/services/nativeImageDecrypt.ts @@ -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 + } +} diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 116ba45..0f6a84b 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -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() diff --git a/electron/utils/pathUtils.ts b/electron/utils/pathUtils.ts new file mode 100644 index 0000000..c1fb638 --- /dev/null +++ b/electron/utils/pathUtils.ts @@ -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 +} + diff --git a/package-lock.json b/package-lock.json index 0c06ec1..4e94508 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 2aac96c..05ef287 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index 63149bc..b1f3ff3 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index 5ac39da..bc8eae2 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index ef07c33..fda2f62 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 05b6d96..dfbf542 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node new file mode 100644 index 0000000..f0c1837 Binary files /dev/null and b/resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node differ diff --git a/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node new file mode 100644 index 0000000..02a4881 Binary files /dev/null and b/resources/wedecrypt/macos/arm64/weflow-image-native-macos-arm64.node differ diff --git a/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node new file mode 100644 index 0000000..eebe65e Binary files /dev/null and b/resources/wedecrypt/win32/arm64/weflow-image-native-win32-arm64.node differ diff --git a/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node new file mode 100644 index 0000000..bea4af9 Binary files /dev/null and b/resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node differ diff --git a/src/App.tsx b/src/App.tsx index a0f11d4..6265a8b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { {/* 全局会话监听与通知 */} - {/* 全局批量转写进度浮窗 */} - - - {/* 用户协议弹窗 */} {showAgreement && !agreementLoading && (
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 7af1bc4..c71b227 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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() - 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(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>() + + 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 = 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(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() + 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>() 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 = 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() @@ -6600,16 +6851,10 @@ function ChatPage(props: ChatPageProps) { {!standaloneSessionWindow && (
@@ -7377,17 +7616,17 @@ function ChatPage(props: ChatPageProps) { className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`} onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)} > - {batchDecryptConcurrency === 1 ? '1(最慢,最稳)' : batchDecryptConcurrency === 6 ? '6(推荐)' : batchDecryptConcurrency === 20 ? '20(最快,可能卡顿)' : String(batchDecryptConcurrency)} + {batchDecryptConcurrency === 1 ? '1' : batchDecryptConcurrency === 6 ? '6' : batchDecryptConcurrency === 20 ? '20' : String(batchDecryptConcurrency)} {showConcurrencyDropdown && (
{[ - { 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 => (
@@ -7768,7 +8007,13 @@ const emojiDataUrlCache = new Map() const imageDataUrlCache = new Map() const voiceDataUrlCache = new Map() const voiceTranscriptCache = new Map() -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>() const senderAvatarCache = new Map() const senderAvatarLoading = new Map>() @@ -7882,7 +8127,7 @@ function MessageBubble({ ) const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` const [imageLocalPath, setImageLocalPath] = useState( - () => 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(null) const imageClickTimerRef = useRef(null) const imageContainerRef = useRef(null) + const imageElementRef = useRef(null) const emojiContainerRef = useRef(null) const imageResizeBaselineRef = useRef(null) const emojiResizeBaselineRef = useRef(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({ <>
图片 = { const backgroundTaskStatusLabels: Record = { 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(

任务中心

- 进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length} + 导出进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 聊天后台 {chatActiveTaskCount} · 总计 {totalTaskCount}
- {tasks.length === 0 ? ( -
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
+ {totalTaskCount === 0 ? ( +
暂无任务。导出任务和聊天页批量语音/图片任务都会显示在这里。
) : (
{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 (
@@ -1734,6 +1939,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
)} + {imageTimingLabel && task.status !== 'queued' && ( +
+ {imageTimingLabel} +
+ )} {canShowPerfDetail && stageTotals && (
累计耗时 {formatDurationMs(stageTotalMs)} @@ -1802,6 +2012,70 @@ const TaskCenterModal = memo(function TaskCenterModal({
) })} + {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 ( +
+
+
{task.title}
+
+ {backgroundTaskStatusLabels[task.status]} + {backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other} + {new Date(task.startedAt).toLocaleString('zh-CN')} +
+ {progress.ratio !== null && ( +
+
+
+ )} +
+ {task.detail || '任务进行中'} + {task.progressText ? ` · ${task.progressText}` : ''} +
+
+
+ {canPause && ( + + )} + {canResume && ( + + )} + +
+
+ ) + })}
)}
@@ -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({ format: 'json', @@ -1924,8 +2197,7 @@ function ExportPage() { excelCompactColumns: true, txtColumns: defaultTxtColumns, displayNamePreference: 'remark', - exportConcurrency: 2, - imageDeepSearchOnMiss: true + exportConcurrency: 2 }) const [exportDialog, setExportDialog] = useState({ @@ -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() const sessionMessageProgress = new Map() 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() { {isAutomationModalOpen && createPortal( @@ -8220,6 +8520,7 @@ function ExportPage() { {queueState === 'queued' && 排队中}

{formatAutomationScheduleLabel(task.schedule)}

+

首次触发:{resolveAutomationFirstTriggerSummary(task)}

时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}

会话范围:{task.sessionIds.length} 个

导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}

@@ -8333,6 +8634,52 @@ function ExportPage() {
+
+ 首次触发时间(可选) + + {automationTaskDraft.firstTriggerAtEnabled && ( +
+ { + 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) + }} + /> + { + const timePart = normalizeAutomationTimePart(event.target.value) + const datePart = normalizeAutomationDatePart(automationTaskDraft.firstTriggerAtValue?.slice(0, 10)) + || buildAutomationTodayDatePart() + setAutomationTaskDraft((prev) => prev ? { + ...prev, + firstTriggerAtValue: `${datePart}T${timePart}` + } : prev) + }} + /> +
+ )} +
+
导出时间范围(按触发时间动态计算)
@@ -8473,7 +8820,11 @@ function ExportPage() {
- 会话:{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)} · 条件:有新消息才导出
@@ -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' + )} > 停止 @@ -9710,30 +10066,6 @@ function ExportPage() {
)} - {shouldRenderImageDeepSearchToggle && ( -
-
-
-
-
-

缺图时深度搜索

-
关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。
-
- -
-
-
-
- )} - {isSessionScopeDialog && (
diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx index 7518647..9d1d0c0 100644 --- a/src/pages/ResourcesPage.tsx +++ b/src/pages/ResourcesPage.tsx @@ -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>(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): string { + const datName = normalizeMediaToken(item.imageDatName) + if (!datName) return '' + return datName +} + +function hasImageLocator(item: Pick): 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 = {} @@ -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() 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 => { + const decryptImage = useCallback(async ( + item: MediaStreamItem, + options?: { allowCacheIndex?: boolean } + ): Promise => { 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 = {} const updatePatch: Record = {} const taskId = registerBackgroundTask({ @@ -1105,32 +1153,71 @@ function ResourcesPage() { lastProgressBucket = bucket lastProgressUpdateAt = now } + const hardlinkMd5Set = new Set() 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', { diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 1dda111..7234964 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -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) diff --git a/src/services/backgroundTaskMonitor.ts b/src/services/backgroundTaskMonitor.ts index 2b41f6d..c379267 100644 --- a/src/services/backgroundTaskMonitor.ts +++ b/src/services/backgroundTaskMonitor.ts @@ -9,10 +9,12 @@ type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void const tasks = new Map() const cancelHandlers = new Map void | Promise>() +const pauseHandlers = new Map void | Promise>() +const resumeHandlers = new Map void | Promise>() const listeners = new Set() let taskSequence = 0 -const ACTIVE_STATUSES = new Set(['running', 'cancel_requested']) +const ACTIVE_STATUSES = new Set(['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) +} diff --git a/src/services/config.ts b/src/services/config.ts index 0741464..0c26875 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -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 { - 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 { - await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled) -} - export type ExportWriteLayout = 'A' | 'B' | 'C' export async function getExportWriteLayout(): Promise { @@ -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 diff --git a/src/stores/batchImageDecryptStore.ts b/src/stores/batchImageDecryptStore.ts index 8e162fb..3601017 100644 --- a/src/stores/batchImageDecryptStore.ts +++ b/src/stores/batchImageDecryptStore.ts @@ -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 + onPause?: () => void | Promise + onResume?: () => void | Promise +} + +interface BatchDecryptFinishOptions { + status?: Extract + 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((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((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((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((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((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}` }) } diff --git a/src/stores/batchTranscribeStore.ts b/src/stores/batchTranscribeStore.ts index 55cf199..a0f11da 100644 --- a/src/stores/batchTranscribeStore.ts +++ b/src/stores/batchTranscribeStore.ts @@ -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 + onPause?: () => void | Promise + onResume?: () => void | Promise +} + +interface BatchVoiceTaskFinishOptions { + status?: Extract + 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((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() + +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((set, get) => ({ isBatchTranscribing: false, taskType: 'transcribe', progress: { current: 0, total: 0 }, @@ -37,41 +84,151 @@ export const useBatchTranscribeStore = create((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 + }) + } })) diff --git a/src/types/backgroundTask.ts b/src/types/backgroundTask.ts index df8315e..ec5cabe 100644 --- a/src/types/backgroundTask.ts +++ b/src/types/backgroundTask.ts @@ -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 + onPause?: () => void | Promise + onResume?: () => void | Promise } export interface BackgroundTaskUpdate { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index dd7a3bb..40ce92a 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -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 + preloadHardlinkMd5s: (md5List: string[]) => Promise 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 { diff --git a/src/types/exportAutomation.ts b/src/types/exportAutomation.ts index 2725f6c..cf2ffea 100644 --- a/src/types/exportAutomation.ts +++ b/src/types/exportAutomation.ts @@ -8,6 +8,7 @@ export type ExportAutomationSchedule = type: 'interval' intervalDays: number intervalHours: number + firstTriggerAt?: number } export interface ExportAutomationCondition {