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..d90ea6b 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 @@ -128,13 +107,15 @@ jobs: run: | export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}' + npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}' - name: Upload macOS arm64 assets to fixed release env: 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..94ad390 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 @@ -157,13 +136,15 @@ jobs: run: | export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}' + npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}' - name: Upload macOS arm64 assets to fixed preview release env: 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..a257720 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 @@ -44,7 +43,7 @@ jobs: npx tsc npx vite build - - name: Package and Publish macOS arm64 (unsigned DMG) + - name: Package and Publish macOS arm64 (unsigned DMG + ZIP) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_IDENTITY_AUTO_DISCOVERY: "false" @@ -52,22 +51,28 @@ jobs: run: | export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/" echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR" - npx electron-builder --mac dmg --arm64 --publish always + npx electron-builder --mac dmg zip --arm64 --publish always - name: Inject minimumVersion into latest yml env: 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 2794d19..4d11ca1 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import './preload-env' +import './preload-env' import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron' import { Worker } from 'worker_threads' import { randomUUID } from 'crypto' @@ -31,6 +31,7 @@ import { destroyNotificationWindow, registerNotificationHandlers, showNotificati import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' import { insightService } from './services/insightService' +import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService' import { bizService } from './services/bizService' // 配置自动更新 @@ -371,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() @@ -1660,6 +1662,32 @@ function registerIpcHandlers() { return insightService.generateFootprintInsight(payload) }) + ipcMain.handle('social:saveWeiboCookie', async (_, rawInput: string) => { + try { + if (!configService) { + return { success: false, error: 'Config service is not initialized' } + } + const normalized = normalizeWeiboCookieInput(rawInput) + configService.set('aiInsightWeiboCookie' as any, normalized as any) + weiboService.clearCache() + return { success: true, normalized, hasCookie: Boolean(normalized) } + } catch (error) { + return { success: false, error: (error as Error).message || 'Failed to save Weibo cookie' } + } + }) + + ipcMain.handle('social:validateWeiboUid', async (_, uid: string) => { + try { + if (!configService) { + return { success: false, error: 'Config service is not initialized' } + } + const cookie = String(configService.get('aiInsightWeiboCookie' as any) || '') + return await weiboService.validateUid(uid, cookie) + } catch (error) { + return { success: false, error: (error as Error).message || 'Failed to validate Weibo UID' } + } + }) + ipcMain.handle('config:clear', async () => { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { const result = setSystemLaunchAtStartup(false) @@ -1863,9 +1891,17 @@ function registerIpcHandlers() { downloadedHandler = null } - // 统一错误提示格式,避免出现 [object Object] 的 JSON 字符串 - const errorMessage = error.message || (typeof error === 'string' ? error : JSON.stringify(error)) - throw new Error(errorMessage) + const errorCode = typeof error?.code === 'string' ? error.code : '' + const rawErrorMessage = + typeof error?.message === 'string' + ? error.message + : (typeof error === 'string' ? error : JSON.stringify(error)) + + if (errorCode === 'ERR_UPDATER_ZIP_FILE_NOT_FOUND' || /ZIP file not provided/i.test(rawErrorMessage)) { + throw new Error('当前发布版本缺少 macOS 自动更新所需的 ZIP 包,请联系开发者重新发布该版本') + } + + throw new Error(rawErrorMessage || '下载更新失败,请稍后重试') } }) @@ -2636,15 +2672,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) }) @@ -2652,17 +2703,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 } } ) @@ -2670,12 +2788,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) => { @@ -3753,23 +3878,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', () => { @@ -3777,3 +3914,7 @@ app.on('window-all-closed', () => { app.quit() } }) + + + + diff --git a/electron/preload.ts b/electron/preload.ts index 9739332..09126a7 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' // 暴露给渲染进程的 API contextBridge.exposeInMainWorld('electronAPI', { @@ -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) @@ -540,5 +557,11 @@ contextBridge.exposeInMainWorld('electronAPI', { privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload) + }, + + social: { + saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput), + validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid) } }) + diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e6da68f..5f305c1 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}` } @@ -4336,9 +4336,9 @@ class ChatService { encrypVer = imageInfo.encrypVer cdnThumbUrl = imageInfo.cdnThumbUrl imageDatName = this.parseImageDatNameFromRow(row) - } else if (localType === 43 && content) { - // 视频消息 - videoMd5 = this.parseVideoMd5(content) + } else if (localType === 43) { + // 视频消息:优先从 packed_info_data 提取真实文件名(32位十六进制),再回退 XML + videoMd5 = this.parseVideoFileNameFromRow(row, content) } else if (localType === 34 && content) { voiceDurationSeconds = this.parseVoiceDurationSeconds(content) } else if (localType === 42 && content) { @@ -4876,7 +4876,20 @@ class ChatService { } private parseImageDatNameFromRow(row: Record): string | undefined { - const packed = row.packed_info_data + const packed = this.getRowField(row, [ + 'packed_info_data', + 'packedInfoData', + 'packed_info_blob', + 'packedInfoBlob', + 'packed_info', + 'packedInfo', + 'BytesExtra', + 'bytes_extra', + 'WCDB_CT_packed_info', + 'reserved0', + 'Reserved0', + 'WCDB_CT_Reserved0' + ]) const buffer = this.decodePackedInfo(packed) if (!buffer || buffer.length === 0) return undefined const printable: number[] = [] @@ -4894,6 +4907,81 @@ class ChatService { return hexMatch?.[1]?.toLowerCase() } + private parseVideoFileNameFromRow(row: Record, content?: string): string | undefined { + const packed = this.getRowField(row, [ + 'packed_info_data', + 'packedInfoData', + 'packed_info_blob', + 'packedInfoBlob', + 'packed_info', + 'packedInfo', + 'BytesExtra', + 'bytes_extra', + 'WCDB_CT_packed_info', + 'reserved0', + 'Reserved0', + 'WCDB_CT_Reserved0' + ]) + const packedToken = this.extractVideoTokenFromPackedRaw(packed) + if (packedToken) return packedToken + + const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [ + 'video_md5', + 'videoMd5', + 'raw_md5', + 'rawMd5', + 'video_file_name', + 'videoFileName' + ])) + if (byColumn) return byColumn + + return this.normalizeVideoFileToken(this.parseVideoMd5(content || '')) + } + + private normalizeVideoFileToken(value: unknown): string | undefined { + let text = String(value || '').trim().toLowerCase() + if (!text) return undefined + text = text.replace(/^.*[\\/]/, '') + text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '') + text = text.replace(/_thumb$/, '') + const directMatch = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) + if (directMatch) { + const suffix = /_raw$/i.test(text) ? '_raw' : '' + return `${directMatch[1].toLowerCase()}${suffix}` + } + const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) + if (preferred32?.[1]) return preferred32[1].toLowerCase() + const generic = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) + return generic?.[1]?.toLowerCase() + } + + private extractVideoTokenFromPackedRaw(raw: unknown): string | undefined { + const buffer = this.decodePackedInfo(raw) + if (!buffer || buffer.length === 0) return undefined + const candidates: string[] = [] + let current = '' + for (const byte of buffer) { + const isHex = + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x46) || + (byte >= 0x61 && byte <= 0x66) + if (isHex) { + current += String.fromCharCode(byte) + continue + } + if (current.length >= 16) candidates.push(current) + current = '' + } + if (current.length >= 16) candidates.push(current) + if (candidates.length === 0) return undefined + + const exact32 = candidates.find((item) => item.length === 32) + if (exact32) return exact32.toLowerCase() + + const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64) + return fallback?.toLowerCase() + } + private decodePackedInfo(raw: any): Buffer | null { if (!raw) return null if (Buffer.isBuffer(raw)) return raw @@ -4901,9 +4989,10 @@ class ChatService { if (Array.isArray(raw)) return Buffer.from(raw) if (typeof raw === 'string') { const trimmed = raw.trim() - if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) { + const compactHex = trimmed.replace(/\s+/g, '') + if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) { try { - return Buffer.from(trimmed, 'hex') + return Buffer.from(compactHex, 'hex') } catch { } } try { @@ -7105,13 +7194,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 +8457,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 +9423,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 +9442,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 +9451,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), @@ -10481,6 +10579,8 @@ class ChatService { const imgInfo = this.parseImageInfo(rawContent) Object.assign(msg, imgInfo) msg.imageDatName = this.parseImageDatNameFromRow(row) + } else if (msg.localType === 43) { // Video + msg.videoMd5 = this.parseVideoFileNameFromRow(row, rawContent) } else if (msg.localType === 47) { // Emoji const emojiInfo = this.parseEmojiInfo(rawContent) msg.emojiCdnUrl = emojiInfo.cdnUrl 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 f7b6f65..a1066f6 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -1,7 +1,8 @@ -import { join } from 'path' +import { join } from 'path' 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[] // 安全相关 @@ -77,12 +77,14 @@ interface ConfigSchema { aiModelApiBaseUrl: string aiModelApiKey: string aiModelApiModel: string + aiModelApiMaxTokens: number aiInsightEnabled: boolean aiInsightApiBaseUrl: string aiInsightApiKey: string aiInsightApiModel: string aiInsightSilenceDays: number aiInsightAllowContext: boolean + aiInsightAllowSocialContext: boolean aiInsightWhitelistEnabled: boolean aiInsightWhitelist: string[] /** 活跃分析冷却时间(分钟),0 表示无冷却 */ @@ -114,7 +116,8 @@ const ENCRYPTED_STRING_KEYS: Set = new Set([ 'authPassword', 'httpApiToken', 'aiModelApiKey', - 'aiInsightApiKey' + 'aiInsightApiKey', + 'aiInsightWeiboCookie' ]) const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) @@ -165,7 +168,6 @@ export class ConfigService { autoTranscribeVoice: false, transcribeLanguages: ['zh'], exportDefaultConcurrency: 4, - exportDefaultImageDeepSearchOnMiss: true, analyticsExcludedUsernames: [], authEnabled: false, authPassword: '', @@ -192,21 +194,26 @@ export class ConfigService { aiModelApiBaseUrl: '', aiModelApiKey: '', aiModelApiModel: 'gpt-4o-mini', + aiModelApiMaxTokens: 200, aiInsightEnabled: false, aiInsightApiBaseUrl: '', aiInsightApiKey: '', aiInsightApiModel: 'gpt-4o-mini', aiInsightSilenceDays: 3, aiInsightAllowContext: false, + aiInsightAllowSocialContext: false, aiInsightWhitelistEnabled: false, aiInsightWhitelist: [], aiInsightCooldownMinutes: 120, aiInsightScanIntervalHours: 4, aiInsightContextCount: 40, + aiInsightSocialContextCount: 3, aiInsightSystemPrompt: '', aiInsightTelegramEnabled: false, aiInsightTelegramToken: '', aiInsightTelegramChatIds: '', + aiInsightWeiboCookie: '', + aiInsightWeiboBindings: {}, aiFootprintEnabled: false, aiFootprintSystemPrompt: '', aiInsightDebugLogEnabled: false @@ -291,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 } @@ -298,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 钥匙串弹窗 @@ -827,3 +842,4 @@ export class ConfigService { this.unlockPassword = null } } + 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..3688afd 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}`) @@ -3782,7 +3780,6 @@ class ExportService { const md5Pattern = /^[a-f0-9]{32}$/i const imageMd5Set = new Set() - const videoMd5Set = new Set() let scanIndex = 0 for (const msg of messages) { @@ -3802,19 +3799,12 @@ class ExportService { } } - if (options.exportVideos && msg?.localType === 43) { - const videoMd5 = String(msg?.videoMd5 || '').trim().toLowerCase() - if (videoMd5) videoMd5Set.add(videoMd5) - } } const preloadTasks: Array> = [] if (imageMd5Set.size > 0) { preloadTasks.push(imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))) } - if (videoMd5Set.size > 0) { - preloadTasks.push(videoService.preloadVideoHardlinkMd5s(Array.from(videoMd5Set))) - } if (preloadTasks.length === 0) return await Promise.all(preloadTasks.map((task) => task.catch(() => { }))) @@ -4104,6 +4094,95 @@ class ExportService { return tagMatch?.[1]?.toLowerCase() } + private decodePackedInfoBuffer(raw: unknown): Buffer | null { + if (!raw) return null + if (Buffer.isBuffer(raw)) return raw + if (raw instanceof Uint8Array) return Buffer.from(raw) + if (Array.isArray(raw)) return Buffer.from(raw) + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed) return null + const compactHex = trimmed.replace(/\s+/g, '') + if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) { + try { + return Buffer.from(compactHex, 'hex') + } catch { } + } + try { + const decoded = Buffer.from(trimmed, 'base64') + if (decoded.length > 0) return decoded + } catch { } + return null + } + if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) { + return Buffer.from((raw as any).data) + } + return null + } + + private normalizeVideoFileToken(value: unknown): string | undefined { + let text = String(value || '').trim().toLowerCase() + if (!text) return undefined + text = text.replace(/^.*[\\/]/, '') + text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '') + text = text.replace(/_thumb$/, '') + const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) + if (direct) { + const suffix = /_raw$/i.test(text) ? '_raw' : '' + return `${direct[1].toLowerCase()}${suffix}` + } + const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) + if (preferred32?.[1]) return preferred32[1].toLowerCase() + const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) + return fallback?.[1]?.toLowerCase() + } + + private extractVideoFileNameFromPackedRaw(raw: unknown): string | undefined { + const buffer = this.decodePackedInfoBuffer(raw) + if (!buffer || buffer.length === 0) return undefined + const candidates: string[] = [] + let current = '' + for (const byte of buffer) { + const isHex = + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x46) || + (byte >= 0x61 && byte <= 0x66) + if (isHex) { + current += String.fromCharCode(byte) + continue + } + if (current.length >= 16) candidates.push(current) + current = '' + } + if (current.length >= 16) candidates.push(current) + if (candidates.length === 0) return undefined + + const exact32 = candidates.find((item) => item.length === 32) + if (exact32) return exact32.toLowerCase() + const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64) + return fallback?.toLowerCase() + } + + private extractVideoFileNameFromRow(row: Record, content?: string): string | undefined { + const packedRaw = this.getRowField(row, [ + 'packed_info_data', 'packedInfoData', + 'packed_info_blob', 'packedInfoBlob', + 'packed_info', 'packedInfo', + 'BytesExtra', 'bytes_extra', + 'WCDB_CT_packed_info', + 'reserved0', 'Reserved0', 'WCDB_CT_Reserved0' + ]) + const byPacked = this.extractVideoFileNameFromPackedRaw(packedRaw) + if (byPacked) return byPacked + + const byColumn = this.normalizeVideoFileToken(this.getRowField(row, [ + 'video_md5', 'videoMd5', 'raw_md5', 'rawMd5', 'video_file_name', 'videoFileName' + ])) + if (byColumn) return byColumn + + return this.normalizeVideoFileToken(this.extractVideoMd5(content || '')) + } + private resolveFileAttachmentRoots(): string[] { const dbPath = String(this.configService.get('dbPath') || '').trim() const rawWxid = String(this.configService.get('myWxid') || '').trim() @@ -4569,7 +4648,7 @@ class ExportService { // 优先复用游标返回的字段,缺失时再回退到 XML 解析。 imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined - videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined + videoMd5 = this.extractVideoFileNameFromRow(row, content) if (localType === 3 && content) { // 图片消息 @@ -4577,7 +4656,7 @@ class ExportService { imageDatName = imageDatName || this.extractImageDatName(content) } else if (localType === 43 && content) { // 视频消息 - videoMd5 = videoMd5 || this.extractVideoMd5(content) + videoMd5 = videoMd5 || this.extractVideoFileNameFromRow(row, content) } else if (collectMode === 'full' && content && (localType === 49 || content.includes(' error?: string }> { let successCount = 0 let failCount = 0 const successSessionIds: string[] = [] const failedSessionIds: string[] = [] + const sessionOutputPaths: Record = {} const progressEmitter = this.createProgressEmitter(onProgress) let attachMediaTelemetry = false const emitProgress = (progress: ExportProgress, options?: { force?: boolean }) => { @@ -9152,7 +9227,8 @@ class ExportService { stopped: true, pendingSessionIds: [...queue], successSessionIds, - failedSessionIds + failedSessionIds, + sessionOutputPaths } } if (pauseRequested) { @@ -9163,7 +9239,8 @@ class ExportService { paused: true, pendingSessionIds: [...queue], successSessionIds, - failedSessionIds + failedSessionIds, + sessionOutputPaths } } @@ -9274,6 +9351,7 @@ class ExportService { if (hasNoDataChange) { successCount++ successSessionIds.push(sessionId) + sessionOutputPaths[sessionId] = preferredOutputPath activeSessionRatios.delete(sessionId) completedCount++ emitProgress({ @@ -9319,6 +9397,7 @@ class ExportService { if (result.success) { successCount++ successSessionIds.push(sessionId) + sessionOutputPaths[sessionId] = outputPath if (typeof messageCountHint === 'number' && messageCountHint >= 0) { exportRecordService.saveRecord(sessionId, effectiveOptions.format, messageCountHint, { sourceLatestMessageTimestamp: typeof latestTimestampHint === 'number' && latestTimestampHint > 0 @@ -9409,7 +9488,8 @@ class ExportService { stopped: true, pendingSessionIds, successSessionIds, - failedSessionIds + failedSessionIds, + sessionOutputPaths } } if (pauseRequested && pendingSessionIds.length > 0) { @@ -9420,7 +9500,8 @@ class ExportService { paused: true, pendingSessionIds, successSessionIds, - failedSessionIds + failedSessionIds, + sessionOutputPaths } } @@ -9433,7 +9514,7 @@ class ExportService { }, { force: true }) progressEmitter.flush() - return { success: true, successCount, failCount, successSessionIds, failedSessionIds } + return { success: true, successCount, failCount, successSessionIds, failedSessionIds, sessionOutputPaths } } catch (e) { progressEmitter.flush() return { success: false, successCount, failCount, error: String(e) } 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..c552ea1 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 hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false - if (isThumb) { - if (!payload.disableUpdateCheck) { - this.triggerUpdateCheck(payload, key, cached) + const upgraded = !this.isHdPath(cached) + ? await this.tryPromoteThumbnailCache(payload, key, cached) + : null + const finalPath = upgraded || cached + const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath) + const isNonHd = !this.isHdPath(finalPath) + const hasUpdate = isNonHd ? (this.updateFlags.get(key) ?? false) : false + if (isNonHd) { + 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,43 +166,60 @@ 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.isHdPath(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 isNonHd = !this.isHdPath(finalPath) + const hasUpdate = isNonHd ? (this.updateFlags.get(cacheKey) ?? false) : false + if (isNonHd) { + 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') if (payload.force) { for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) - if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) { + if (cached && existsSync(cached) && this.isImageFile(cached) && this.isHdPath(cached)) { this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached) this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName) const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath) @@ -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.isHdPath(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 } } @@ -251,22 +280,13 @@ export class ImageDecryptService { if (!accountDir) return try { - const ready = await this.ensureWcdbReady() - if (!ready) return - const requests = normalizedList.map((md5) => ({ md5, accountDir })) - const result = await wcdbService.resolveImageHardlinkBatch(requests) - if (!result.success || !Array.isArray(result.rows)) return - - 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) - } + for (const md5 of normalizedList) { + if (!this.looksLikeMd5(md5)) continue + const selectedPath = this.selectBestDatPathByBase(accountDir, md5, undefined, undefined, true) + if (!selectedPath) continue + this.cacheDatPath(accountDir, md5, selectedPath) + const fileName = basename(selectedPath).toLowerCase() + if (fileName) this.cacheDatPath(accountDir, fileName, selectedPath) } } catch { // ignore preload failures @@ -285,14 +305,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 +327,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 +341,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 +363,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 +377,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 +394,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 +426,57 @@ 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) + const isHdCache = this.isHdPath(outputPath) + this.removeDuplicateCacheCandidates(datPath, payload.sessionId, outputPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) - if (!isThumb) { + if (isHdCache) { 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 +488,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 +521,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 +532,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,305 +601,631 @@ 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(不进行全局搜索) - 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 }) - } - } - - 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 }) + const lookupBases = this.collectLookupBasesForScan(imageMd5, imageDatName, allowDatNameScanFallback) + if (lookupBases.length === 0) { + this.logInfo('[ImageDecrypt] resolveDatPath miss (no lookup base)', { 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([ + ...lookupBases, + 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 || !existsSync(cached)) continue + if (!allowThumbnail && !this.isHdDatPath(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 baseMd5 of lookupBases) { + const selectedPath = this.selectBestDatPathByBase(accountDir, baseMd5, sessionId, createTime, allowThumbnail) + if (!selectedPath) continue + + this.cacheDatPath(accountDir, baseMd5, selectedPath) + if (imageMd5) this.cacheDatPath(accountDir, imageMd5, selectedPath) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, selectedPath) + const normalizedFileName = basename(selectedPath).toLowerCase() + if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, selectedPath) + this.logInfo('[ImageDecrypt] dat scan selected', { + baseMd5, + selectedPath, + allowThumbnail + }) + return selectedPath } - for (const searchName of searchNames) { - const normalized = this.normalizeDatBase(searchName) - if (normalized !== searchName.toLowerCase()) { - const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail) - if (normalizedPath) { - this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath }) - if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath) - if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath) - this.cacheDatPath(accountDir, searchName, normalizedPath) - if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath) - if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath) - return normalizedPath - } - } - } - this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames }) + this.logInfo('[ImageDecrypt] resolveDatPath miss (dat scan)', { + imageMd5, + imageDatName, + lookupBases, + allowThumbnail + }) 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 - const isThumbnail = this.isThumbnailPath(cachedPath) - if (!isThumbnail) return false + if (this.isHdPath(cachedPath)) return false const wxid = this.configService.get('myWxid') const dbPath = this.configService.get('dbPath') if (!wxid || !dbPath) return false 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 lookupBases = this.collectLookupBasesForScan(payload.imageMd5, payload.imageDatName, true) + if (lookupBases.length === 0) return false + + let currentTier = this.getCachedPathTier(cachedPath) + let bestDatPath: string | null = null + let bestDatTier = -1 + for (const baseMd5 of lookupBases) { + const candidate = this.selectBestDatPathByBase(accountDir, baseMd5, payload.sessionId, payload.createTime, true) + if (!candidate) continue + const candidateTier = this.getDatTier(candidate, baseMd5) + if (candidateTier <= 0) continue + if (!bestDatPath) { + bestDatPath = candidate + bestDatTier = candidateTier + continue + } + if (candidateTier > bestDatTier) { + bestDatPath = candidate + bestDatTier = candidateTier + continue + } + if (candidateTier === bestDatTier) { + const candidateSize = this.fileSizeSafe(candidate) + const bestSize = this.fileSizeSafe(bestDatPath) + if (candidateSize > bestSize) { + bestDatPath = candidate + bestDatTier = candidateTier + } } } + if (!bestDatPath || bestDatTier <= 0) return false + if (currentTier < 0) currentTier = 1 + return bestDatTier > currentTier + } - const thumbPath = await this.resolveThumbnailDatPath( + 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.isHdPath(cachedPath)) return null + + const accountDir = this.resolveCurrentAccountDir() + if (!accountDir) return null + + const hdDatPath = 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 - } + if (!hdDatPath) return null + + const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true) + if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && this.isHdPath(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.isHdPath(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.isHdPath(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 + preferFilePath?: boolean + 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) => { + void this.checkHasUpdate(payload, cacheKey, cachedPath).then(async (hasUpdate) => { if (!hasUpdate) return this.updateFlags.set(cacheKey, true) + const upgradedPath = await this.tryAutoRefreshBetterCache(payload, cacheKey, cachedPath) + if (upgradedPath) { + this.updateFlags.delete(cacheKey) + this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(upgradedPath, payload.preferFilePath)) + return + } this.emitImageUpdate(payload, cacheKey) }).catch(() => { }) } + private async tryAutoRefreshBetterCache( + payload: { + sessionId?: string + imageMd5?: string + imageDatName?: string + createTime?: number + preferFilePath?: boolean + disableUpdateCheck?: boolean + suppressEvents?: boolean + }, + cacheKey: string, + cachedPath: string + ): Promise { + if (!cachedPath || !existsSync(cachedPath)) return null + if (this.isHdPath(cachedPath)) return null + const refreshed = await this.decryptImage({ + sessionId: payload.sessionId, + imageMd5: payload.imageMd5, + imageDatName: payload.imageDatName, + createTime: payload.createTime, + preferFilePath: true, + force: true, + hardlinkOnly: true, + disableUpdateCheck: true, + suppressEvents: true + }) + if (!refreshed.success || !refreshed.localPath) return null + const refreshedPath = String(refreshed.localPath || '').trim() + if (!refreshedPath || !existsSync(refreshedPath)) return null + if (!this.isImageFile(refreshedPath)) return null + this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, refreshedPath) + this.removeThumbnailCacheFile(cachedPath, refreshedPath) + return refreshedPath + } - 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 collectLookupBasesForScan(imageMd5?: string, imageDatName?: string, allowDatNameScanFallback = true): string[] { + const bases = this.collectHardlinkLookupMd5s(imageMd5, imageDatName) + if (!allowDatNameScanFallback) return bases + const fallbackRaw = String(imageDatName || imageMd5 || '').trim().toLowerCase() + if (!fallbackRaw) return bases + const fallbackNoExt = fallbackRaw.endsWith('.dat') ? fallbackRaw.slice(0, -4) : fallbackRaw + const fallbackBase = this.normalizeDatBase(fallbackNoExt) + if (this.looksLikeMd5(fallbackBase) && !bases.includes(fallbackBase)) { + bases.push(fallbackBase) + } + return bases + } + + private collectAllDatCandidatesForBase( + accountDir: string, + baseMd5: string, + sessionId?: string, + createTime?: number + ): string[] { + const sessionMonth = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime) + return Array.from(new Set(sessionMonth.filter((item) => { + const path = String(item || '').trim() + return path && existsSync(path) && path.toLowerCase().endsWith('.dat') + }))) + } + + private isImgScopedDatPath(filePath: string): boolean { + const lower = String(filePath || '').toLowerCase() + return /[\\/](img|image|msgimg)[\\/]/.test(lower) + } + + private fileSizeSafe(filePath: string): number { + try { + return statSync(filePath).size || 0 + } catch { + return 0 + } + } + + private fileMtimeSafe(filePath: string): number { + try { + return statSync(filePath).mtimeMs || 0 + } catch { + return 0 + } + } + + private pickLargestDatPath(paths: string[]): string | null { + const list = Array.from(new Set(paths.filter(Boolean))) + if (list.length === 0) return null + list.sort((a, b) => { + const sizeDiff = this.fileSizeSafe(b) - this.fileSizeSafe(a) + if (sizeDiff !== 0) return sizeDiff + const mtimeDiff = this.fileMtimeSafe(b) - this.fileMtimeSafe(a) + if (mtimeDiff !== 0) return mtimeDiff + return a.localeCompare(b) + }) + return list[0] || null + } + + private selectBestDatPathByBase( + accountDir: string, + baseMd5: string, + sessionId?: string, + createTime?: number, + allowThumbnail = true + ): string | null { + const candidates = this.collectAllDatCandidatesForBase(accountDir, baseMd5, sessionId, createTime) + if (candidates.length === 0) return null + + const imgCandidates = candidates.filter((item) => this.isImgScopedDatPath(item)) + const imgHdCandidates = imgCandidates.filter((item) => this.isHdDatPath(item)) + const hdInImg = this.pickLargestDatPath(imgHdCandidates) + if (hdInImg) return hdInImg + + if (!allowThumbnail) { + // 高清优先仅认 img/image/msgimg 路径中的 H 变体; + // 若该范围没有,则交由 allowThumbnail=true 的回退分支按 base.dat/_t 继续挑选。 + return null + } + + // 无 H 时,优先尝试原始无后缀 DAT({md5}.dat)。 + const baseDatInImg = this.pickLargestDatPath( + imgCandidates.filter((item) => this.isBaseDatPath(item, baseMd5)) + ) + if (baseDatInImg) return baseDatInImg + + const baseDatAny = this.pickLargestDatPath( + candidates.filter((item) => this.isBaseDatPath(item, baseMd5)) + ) + if (baseDatAny) return baseDatAny + + const thumbDatInImg = this.pickLargestDatPath( + imgCandidates.filter((item) => this.isTVariantDat(item)) + ) + if (thumbDatInImg) return thumbDatInImg + + const thumbDatAny = this.pickLargestDatPath( + candidates.filter((item) => this.isTVariantDat(item)) + ) + if (thumbDatAny) return thumbDatAny + return null } + 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.isHdDatPath(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 + } + } + + // 新策略:只扫描会话月目录,不做 account-wide 根目录回退。 + this.datNameScanMissAt.set(missKey, Date.now()) + this.logInfo('[ImageDecrypt] datName fallback precise scan miss', { + accountDir, + sessionId, + imageDatName: datNameRaw, + createTime, + monthKey, + baseMd5, + allowThumbnail + }) + 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 sessionDir = this.resolveSessionDirForStorage(normalizedSessionId) + if (!sessionDir) return [] + const candidates = new Set() + const budget = { remaining: 240 } + const targetDirs: Array<{ dir: string; depth: number }> = [ + // 1) accountDir/msg/attach/{sessionMd5}/{yyyy-MM}/Img + { dir: join(accountDir, 'msg', 'attach', sessionDir, monthKey, 'Img'), depth: 1 } + ] + + for (const target of targetDirs) { + if (budget.remaining <= 0) break + this.scanDatCandidatesUnderRoot(target.dir, baseMd5, target.depth, candidates, budget) + } + + return Array.from(candidates) + } + + private resolveSessionDirForStorage(sessionId: string): string { + const normalized = String(sessionId || '').trim().toLowerCase() + if (!normalized) return '' + if (this.looksLikeMd5(normalized)) return normalized + const cleaned = this.cleanAccountDirName(normalized).toLowerCase() + if (this.looksLikeMd5(cleaned)) return cleaned + return crypto.createHash('md5').update(cleaned || normalized).digest('hex').toLowerCase() + } + + 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 +1257,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 +1271,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,144 +1285,130 @@ export class ImageDecryptService { } } - private hasImageVariantSuffix(baseLower: string): boolean { - return this.stripDatVariantSuffix(baseLower) !== baseLower + private getCacheVariantSuffixFromDat(datPath: string): string { + if (this.isHdDatPath(datPath)) return '_hd' + const name = basename(datPath) + const lower = name.toLowerCase() + const stem = lower.endsWith('.dat') ? lower.slice(0, -4) : lower + const base = this.normalizeDatBase(stem) + const rawSuffix = stem.slice(base.length) + if (!rawSuffix) return '' + const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '') + if (!safe) return '' + if (safe.startsWith('_') || safe.startsWith('.')) return safe + return `_${safe}` } - private isLikelyImageDatBase(baseLower: string): boolean { - return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower)) + private getCacheVariantSuffixFromCachedPath(cachePath: string): string { + const raw = String(cachePath || '').split('?')[0] + const name = basename(raw) + const ext = extname(name).toLowerCase() + const stem = (ext ? name.slice(0, -ext.length) : name).toLowerCase() + const base = this.normalizeDatBase(stem) + const rawSuffix = stem.slice(base.length) + if (!rawSuffix) return '' + const safe = rawSuffix.replace(/[^a-z0-9._-]/g, '') + if (!safe) return '' + if (safe.startsWith('_') || safe.startsWith('.')) return safe + return `_${safe}` } - - - 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 buildCacheSuffixSearchOrder(primarySuffix: string, preferHd: boolean): string[] { + const fallbackSuffixes = [ + '_hd', + '_thumb', + '_t', + '.t', + '_b', + '.b', + '_w', + '.w', + '_c', + '.c', + '' + ] + const ordered = preferHd + ? ['_hd', primarySuffix, ...fallbackSuffixes] + : [primarySuffix, '_hd', ...fallbackSuffixes] + return Array.from(new Set(ordered.map((item) => String(item || '').trim()).filter((item) => item.length >= 0))) } private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string { const name = basename(datPath) const lower = name.toLowerCase() - const base = lower.endsWith('.dat') ? name.slice(0, -4) : name - - // 提取基础名称(去掉 _t, _h 等后缀) + const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower const normalizedBase = this.normalizeDatBase(base) - - // 判断是缩略图还是高清图 - const isThumb = this.isThumbnailDat(lower) - const suffix = isThumb ? '_thumb' : '_hd' + const suffix = this.getCacheVariantSuffixFromDat(datPath) 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') ? lower.slice(0, -4) : lower + const normalizedBase = this.normalizeDatBase(base) + const primarySuffix = this.getCacheVariantSuffixFromDat(datPath) + const suffixes = this.buildCacheSuffixSearchOrder(primarySuffix, preferHd) + 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 removeDuplicateCacheCandidates(datPath: string, sessionId: string | undefined, keepPath: string): void { + const candidateSets = [ + ...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, false), + ...this.buildCacheOutputCandidatesFromDat(datPath, sessionId, true) + ] + const candidates = Array.from(new Set(candidateSets)) + for (const candidate of candidates) { + if (!candidate || candidate === keepPath) continue + if (!existsSync(candidate)) continue + if (!this.isImageFile(candidate)) continue + void rm(candidate, { force: true }).catch(() => { }) + } + } + + 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 +1451,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 +1489,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 +1507,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 +1630,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 +1679,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 +1737,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 +1749,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 +1778,7 @@ export class ImageDecryptService { const args = [ '-hide_banner', '-loglevel', 'error', + '-y', ...inputArgs, '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput ] @@ -2015,13 +1833,54 @@ 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 isHdDatPath(datPath: string): boolean { + const name = basename(String(datPath || '')).toLowerCase() + if (!name.endsWith('.dat')) return false + const stem = name.slice(0, -4) + return ( + stem.endsWith('_h') || + stem.endsWith('.h') || + stem.endsWith('_hd') || + stem.endsWith('.hd') + ) + } + + private isTVariantDat(datPath: string): boolean { + const name = basename(String(datPath || '')).toLowerCase() + return this.isThumbnailDat(name) + } + + private isBaseDatPath(datPath: string, baseMd5: string): boolean { + const normalizedBase = String(baseMd5 || '').trim().toLowerCase() + if (!normalizedBase) return false + const name = basename(String(datPath || '')).toLowerCase() + return name === `${normalizedBase}.dat` + } + + private getDatTier(datPath: string, baseMd5: string): number { + if (this.isHdDatPath(datPath)) return 3 + if (this.isBaseDatPath(datPath, baseMd5)) return 2 + if (this.isTVariantDat(datPath)) return 1 + return 0 + } + + private getCachedPathTier(cachePath: string): number { + if (this.isHdPath(cachePath)) return 3 + const suffix = this.getCacheVariantSuffixFromCachedPath(cachePath) + if (!suffix) return 2 + const normalized = suffix.toLowerCase() + if (normalized === '_t' || normalized === '.t' || normalized === '_thumb' || normalized === '.thumb') { + return 1 + } + return 1 } private isHdPath(p: string): boolean { - return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h') + const raw = String(p || '').split('?')[0] + const name = basename(raw).toLowerCase() + const ext = extname(name).toLowerCase() + const stem = ext ? name.slice(0, -ext.length) : name + return stem.endsWith('_hd') } private isThumbnailPath(p: string): boolean { @@ -2044,42 +1903,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/insightService.ts b/electron/services/insightService.ts index 911af51..f1ee5b4 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -1,4 +1,4 @@ -/** +/** * insightService.ts * * AI 见解后台服务: @@ -21,6 +21,7 @@ import { URL } from 'url' import { app, Notification } from 'electron' import { ConfigService } from './config' import { chatService, ChatSession, Message } from './chatService' +import { weiboService } from './social/weiboService' // ─── 常量 ──────────────────────────────────────────────────────────────────── @@ -35,7 +36,9 @@ const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000 /** 单次 API 请求超时(毫秒) */ const API_TIMEOUT_MS = 45_000 -const API_MAX_TOKENS = 200 +const API_MAX_TOKENS_DEFAULT = 200 +const API_MAX_TOKENS_MIN = 1 +const API_MAX_TOKENS_MAX = 65_535 const API_TEMPERATURE = 0.7 /** 沉默天数阈值默认值 */ @@ -46,6 +49,11 @@ const INSIGHT_CONFIG_KEYS = new Set([ 'aiModelApiBaseUrl', 'aiModelApiKey', 'aiModelApiModel', + 'aiModelApiMaxTokens', + 'aiInsightAllowSocialContext', + 'aiInsightSocialContextCount', + 'aiInsightWeiboCookie', + 'aiInsightWeiboBindings', 'dbPath', 'decryptKey', 'myWxid' @@ -62,6 +70,7 @@ interface SharedAiModelConfig { apiBaseUrl: string apiKey: string model: string + maxTokens: number } // ─── 日志 ───────────────────────────────────────────────────────────────────── @@ -166,6 +175,27 @@ function formatTimestamp(ts: number): string { return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } +function formatPromptCurrentTime(date: Date = new Date()): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `当前系统时间:${year}年${month}月${day}日 ${hours}:${minutes}` +} + +function appendPromptCurrentTime(prompt: string): string { + const base = String(prompt || '').trimEnd() + if (!base) return formatPromptCurrentTime() + return `${base}\n\n${formatPromptCurrentTime()}` +} + +function normalizeApiMaxTokens(value: unknown): number { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return API_MAX_TOKENS_DEFAULT + return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric))) +} + /** * 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。 * 使用 Node 原生 https/http 模块,无需任何第三方 SDK。 @@ -175,7 +205,8 @@ function callApi( apiKey: string, model: string, messages: Array<{ role: string; content: string }>, - timeoutMs: number = API_TIMEOUT_MS + timeoutMs: number = API_TIMEOUT_MS, + maxTokens: number = API_MAX_TOKENS_DEFAULT ): Promise { return new Promise((resolve, reject) => { const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') @@ -190,7 +221,7 @@ function callApi( const body = JSON.stringify({ model, messages, - max_tokens: API_MAX_TOKENS, + max_tokens: normalizeApiMaxTokens(maxTokens), temperature: API_TEMPERATURE, stream: false }) @@ -318,6 +349,10 @@ class InsightService { if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return // 数据库相关配置变更后,丢弃缓存并强制下次重连 + if (normalizedKey === 'aiInsightAllowSocialContext' || normalizedKey === 'aiInsightSocialContextCount' || normalizedKey === 'aiInsightWeiboCookie' || normalizedKey === 'aiInsightWeiboBindings') { + weiboService.clearCache() + } + if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') { this.clearRuntimeCache() } @@ -350,6 +385,7 @@ class InsightService { this.lastSeenTimestamp.clear() this.todayTriggers.clear() this.todayDate = getStartOfDay() + weiboService.clearCache() } private clearTimers(): void { @@ -392,7 +428,7 @@ class InsightService { * 供设置页"测试连接"按钮调用。 */ async testConnection(): Promise<{ success: boolean; message: string }> { - const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() + const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() if (!apiBaseUrl || !apiKey) { return { success: false, message: '请先填写 API 地址和 API Key' } @@ -400,13 +436,14 @@ class InsightService { try { const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') - const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }] + const requestMessages = [{ role: 'user', content: appendPromptCurrentTime('请回复"连接成功"四个字。') }] insightDebugSection( 'INFO', 'AI 测试连接请求', [ `Endpoint: ${endpoint}`, `Model: ${model}`, + `Max Tokens: ${maxTokens}`, '', '用户提示词:', requestMessages[0].content @@ -418,7 +455,8 @@ class InsightService { apiKey, model, requestMessages, - 15_000 + 15_000, + maxTokens ) insightDebugSection('INFO', 'AI 测试连接输出原文', result) return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` } @@ -505,7 +543,7 @@ class InsightService { return { success: false, message: '请先在设置中开启「AI 足迹总结」' } } - const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() + const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() if (!apiBaseUrl || !apiKey) { return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } } @@ -545,7 +583,7 @@ class InsightService { const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim() const systemPrompt = customPrompt || defaultSystemPrompt - const userPrompt = `统计范围:${rangeLabel} + const userPromptBase = `统计范围:${rangeLabel} 有聊天的人数:${Number(summary.private_inbound_people) || 0} 我有回复的人数:${Number(summary.private_outbound_people) || 0} 回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}% @@ -559,6 +597,7 @@ ${topPrivateText} ${topMentionText} 请给出足迹复盘(2-3句,含建议):` + const userPrompt = appendPromptCurrentTime(userPromptBase) try { const result = await callApi( @@ -569,7 +608,8 @@ ${topMentionText} { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ], - 25_000 + 25_000, + maxTokens ) const insight = result.trim().slice(0, 400) if (!insight) return { success: false, message: '模型返回为空' } @@ -601,8 +641,9 @@ ${topMentionText} || this.config.get('aiInsightApiModel') || 'gpt-4o-mini' ).trim() || 'gpt-4o-mini' + const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens')) - return { apiBaseUrl, apiKey, model } + return { apiBaseUrl, apiKey, model, maxTokens } } private looksLikeWxid(text: string): boolean { @@ -786,6 +827,50 @@ ${topMentionText} return total } + private formatWeiboTimestamp(raw: string): string { + const parsed = Date.parse(String(raw || '')) + if (!Number.isFinite(parsed)) { + return String(raw || '').trim() + } + return new Date(parsed).toLocaleString('zh-CN') + } + + private async getSocialContextSection(sessionId: string): Promise { + const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true + if (!allowSocialContext) return '' + + const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim() + const hasCookie = rawCookie.length > 0 + + const bindings = + (this.config.get('aiInsightWeiboBindings') as Record | undefined) || {} + const binding = bindings[sessionId] + const uid = String(binding?.uid || '').trim() + if (!uid) return '' + + const socialCountRaw = Number(this.config.get('aiInsightSocialContextCount') || 3) + const socialCount = Math.max(1, Math.min(5, Math.floor(socialCountRaw) || 3)) + + try { + const posts = await weiboService.fetchRecentPosts(uid, rawCookie, socialCount) + if (posts.length === 0) return '' + + const lines = posts.map((post) => { + const time = this.formatWeiboTimestamp(post.createdAt) + const text = post.text.length > 180 ? `${post.text.slice(0, 180)}...` : post.text + return `[微博 ${time}] ${text}` + }) + insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`) + const riskHint = hasCookie + ? '' + : '\n提示:未配置微博 Cookie,使用移动端公开接口抓取,可能因平台风控导致获取失败或内容较少。' + return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}${riskHint}` + } catch (error) { + insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`) + return '' + } + } + // ── 沉默联系人扫描 ────────────────────────────────────────────────────────── private scheduleSilenceScan(): void { @@ -996,7 +1081,7 @@ ${topMentionText} if (!sessionId) return if (!this.isEnabled()) return - const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() + const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig() const allowContext = this.config.get('aiInsightAllowContext') as boolean const contextCount = (this.config.get('aiInsightContextCount') as number) || 40 const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName) @@ -1028,6 +1113,8 @@ ${topMentionText} } } + const socialContextSection = await this.getSocialContextSection(sessionId) + // ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)──── const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。 @@ -1055,13 +1142,15 @@ ${topMentionText} const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。` - const userPrompt = [ + const userPromptBase = [ `触发原因:${triggerDesc}`, `时间统计:${todayStatsDesc}`, `全局统计:${globalStatsDesc}`, contextSection, + socialContextSection, '请给出你的见解(≤80字):' ].filter(Boolean).join('\n\n') + const userPrompt = appendPromptCurrentTime(userPromptBase) const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') const requestMessages = [ @@ -1076,6 +1165,7 @@ ${topMentionText} [ `接口地址:${endpoint}`, `模型:${model}`, + `Max Tokens:${maxTokens}`, `触发原因:${triggerReason}`, `上下文开关:${allowContext ? '开启' : '关闭'}`, `上下文条数:${contextCount}`, @@ -1093,7 +1183,9 @@ ${topMentionText} apiBaseUrl, apiKey, model, - requestMessages + requestMessages, + API_TIMEOUT_MS, + maxTokens ) insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`) @@ -1190,3 +1282,5 @@ ${topMentionText} } export const insightService = new InsightService() + + 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..fa8f3ad 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -2,6 +2,10 @@ import { ConfigService } from './config' import { chatService, type ChatSession, type Message } from './chatService' import { wcdbService } from './wcdbService' import { httpService } from './httpService' +import { promises as fs } from 'fs' +import path from 'path' +import { createHash } from 'crypto' +import { pathToFileURL } from 'url' interface SessionBaseline { lastTimestamp: number @@ -33,6 +37,8 @@ class MessagePushService { private readonly sessionBaseline = new Map() private readonly recentMessageKeys = new Map() private readonly groupNicknameCache = new Map; updatedAt: number }>() + private readonly pushAvatarCacheDir: string + private readonly pushAvatarDataCache = new Map() private readonly debounceMs = 350 private readonly recentMessageTtlMs = 10 * 60 * 1000 private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000 @@ -45,6 +51,7 @@ class MessagePushService { constructor() { this.configService = ConfigService.getInstance() + this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files') } start(): void { @@ -53,6 +60,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 @@ -303,12 +317,13 @@ class MessagePushService { const groupInfo = await chatService.getContactAvatar(sessionId) const groupName = session.displayName || groupInfo?.displayName || sessionId const sourceName = await this.resolveGroupSourceName(sessionId, message, session) + const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl) return { event: 'message.new', sessionId, sessionType, messageKey, - avatarUrl: session.avatarUrl || groupInfo?.avatarUrl, + avatarUrl, groupName, sourceName, content @@ -316,17 +331,63 @@ class MessagePushService { } const contactInfo = await chatService.getContactAvatar(sessionId) + const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl) return { event: 'message.new', sessionId, sessionType, messageKey, - avatarUrl: session.avatarUrl || contactInfo?.avatarUrl, + avatarUrl, sourceName: session.displayName || contactInfo?.displayName || sessionId, content } } + private async normalizePushAvatarUrl(avatarUrl?: string): Promise { + const normalized = String(avatarUrl || '').trim() + if (!normalized) return undefined + if (!normalized.startsWith('data:image/')) { + return normalized + } + + const cached = this.pushAvatarDataCache.get(normalized) + if (cached) return cached + + const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized) + if (!match) return undefined + + try { + const mimeType = match[1].toLowerCase() + const base64Data = match[2] + const imageBuffer = Buffer.from(base64Data, 'base64') + if (!imageBuffer.length) return undefined + + const ext = this.getImageExtFromMime(mimeType) + const hash = createHash('sha1').update(normalized).digest('hex') + const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`) + + await fs.mkdir(this.pushAvatarCacheDir, { recursive: true }) + try { + await fs.access(filePath) + } catch { + await fs.writeFile(filePath, imageBuffer) + } + + const fileUrl = pathToFileURL(filePath).toString() + this.pushAvatarDataCache.set(normalized, fileUrl) + return fileUrl + } catch { + return undefined + } + } + + private getImageExtFromMime(mimeType: string): string { + if (mimeType === 'image/png') return 'png' + if (mimeType === 'image/gif') return 'gif' + if (mimeType === 'image/webp') return 'webp' + return 'jpg' + } + private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] { if (sessionId.endsWith('@chatroom')) { return 'group' 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/snsService.ts b/electron/services/snsService.ts index 8d3fed7..69f5841 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService' import { ConfigService } from './config' import { ContactCacheService } from './contactCacheService' import { app } from 'electron' -import { existsSync, mkdirSync } from 'fs' +import { existsSync, mkdirSync, unlinkSync } from 'fs' import { readFile, writeFile, mkdir } from 'fs/promises' import { basename, join } from 'path' import crypto from 'crypto' @@ -174,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => { // BMP if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp' - // MP4: 00 00 00 18 / 20 / ... + 'ftyp' - if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4' + // ISO BMFF 家族:优先识别 AVIF/HEIF,避免误判为 MP4 + if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) { + const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase() + if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif' + if ( + ftypWindow.includes('heic') || ftypWindow.includes('heix') || + ftypWindow.includes('hevc') || ftypWindow.includes('hevx') || + ftypWindow.includes('mif1') || ftypWindow.includes('msf1') + ) return 'image/heic' + return 'video/mp4' + } // Fallback logic for video if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4' @@ -1231,7 +1240,19 @@ class SnsService { const cacheKey = `${url}|${key ?? ''}` if (this.imageCache.has(cacheKey)) { - return { success: true, dataUrl: this.imageCache.get(cacheKey) } + const cachedDataUrl = this.imageCache.get(cacheKey) || '' + const base64Part = cachedDataUrl.split(',')[1] || '' + if (base64Part) { + try { + const cachedBuf = Buffer.from(base64Part, 'base64') + if (detectImageMime(cachedBuf, '').startsWith('image/')) { + return { success: true, dataUrl: cachedDataUrl } + } + } catch { + // ignore and fall through to refetch + } + } + this.imageCache.delete(cacheKey) } const result = await this.fetchAndDecryptImage(url, key) @@ -1244,6 +1265,9 @@ class SnsService { } if (result.data && result.contentType) { + if (!detectImageMime(result.data, '').startsWith('image/')) { + return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' } + } const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}` this.imageCache.set(cacheKey, dataUrl) return { success: true, dataUrl } @@ -1853,8 +1877,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class } const data = await readFile(cachePath) - const contentType = detectImageMime(data) - return { success: true, data, contentType, cachePath } + if (!detectImageMime(data, '').startsWith('image/')) { + // 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。 + try { unlinkSync(cachePath) } catch { } + } else { + const contentType = detectImageMime(data) + return { success: true, data, contentType, cachePath } + } } catch (e) { console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e) } @@ -2006,6 +2035,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class const xEnc = String(res.headers['x-enc'] || '').trim() let decoded = raw + const rawMagicMime = detectImageMime(raw, '') // 图片逻辑 const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0 @@ -2023,13 +2053,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class decrypted[i] = raw[i] ^ keystream[i] } - decoded = decrypted + const decryptedMagicMime = detectImageMime(decrypted, '') + if (decryptedMagicMime.startsWith('image/')) { + decoded = decrypted + } else if (!rawMagicMime.startsWith('image/')) { + decoded = decrypted + } } } catch (e) { console.error('[SnsService] TS Decrypt Error:', e) } } + const decodedMagicMime = detectImageMime(decoded, '') + if (!decodedMagicMime.startsWith('image/')) { + resolve({ success: false, error: '图片解密失败:无法识别图片格式' }) + return + } + // 写入磁盘缓存 try { await writeFile(cachePath, decoded) @@ -2063,6 +2104,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true + if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) { + const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase() + if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true + if ( + ftypWindow.includes('heic') || ftypWindow.includes('heix') || + ftypWindow.includes('hevc') || ftypWindow.includes('hevx') || + ftypWindow.includes('mif1') || ftypWindow.includes('msf1') + ) return true + } return false } diff --git a/electron/services/social/weiboService.ts b/electron/services/social/weiboService.ts new file mode 100644 index 0000000..30a9a5f --- /dev/null +++ b/electron/services/social/weiboService.ts @@ -0,0 +1,367 @@ +import https from 'https' +import { createHash } from 'crypto' +import { URL } from 'url' + +const WEIBO_TIMEOUT_MS = 10_000 +const WEIBO_MAX_POSTS = 5 +const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000 +const WEIBO_USER_AGENT = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36' +const WEIBO_MOBILE_USER_AGENT = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' + +interface BrowserCookieEntry { + domain?: string + name?: string + value?: string +} + +interface WeiboUserInfo { + id?: number | string + screen_name?: string +} + +interface WeiboWaterFallItem { + id?: number | string + idstr?: string + mblogid?: string + created_at?: string + text_raw?: string + isLongText?: boolean + user?: WeiboUserInfo + retweeted_status?: WeiboWaterFallItem +} + +interface WeiboWaterFallResponse { + ok?: number + data?: { + list?: WeiboWaterFallItem[] + next_cursor?: string + } +} + +interface WeiboStatusShowResponse { + id?: number | string + idstr?: string + mblogid?: string + created_at?: string + text_raw?: string + user?: WeiboUserInfo + retweeted_status?: WeiboWaterFallItem +} + +interface MWeiboCard { + mblog?: WeiboWaterFallItem + card_group?: MWeiboCard[] +} + +interface MWeiboContainerResponse { + ok?: number + data?: { + cards?: MWeiboCard[] + } +} + +export interface WeiboRecentPost { + id: string + createdAt: string + url: string + text: string + screenName?: string +} + +interface CachedRecentPosts { + expiresAt: number + posts: WeiboRecentPost[] +} + +function requestJson(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise { + return new Promise((resolve, reject) => { + let urlObj: URL + try { + urlObj = new URL(url) + } catch { + reject(new Error(`无效的微博请求地址:${url}`)) + return + } + + const headers: Record = { + Accept: 'application/json, text/plain, */*', + Referer: options.referer || 'https://weibo.com', + 'User-Agent': options.userAgent || WEIBO_USER_AGENT, + 'X-Requested-With': 'XMLHttpRequest' + } + if (options.cookie) { + headers.Cookie = options.cookie + } + + const req = https.request( + { + hostname: urlObj.hostname, + port: urlObj.port || 443, + path: urlObj.pathname + urlObj.search, + method: 'GET', + headers + }, + (res) => { + let raw = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { + raw += chunk + }) + res.on('end', () => { + const statusCode = res.statusCode || 0 + if (statusCode < 200 || statusCode >= 300) { + reject(new Error(`微博接口返回异常状态码 ${statusCode}`)) + return + } + try { + resolve(JSON.parse(raw) as T) + } catch { + reject(new Error('微博接口返回了非 JSON 响应')) + } + }) + } + ) + + req.setTimeout(WEIBO_TIMEOUT_MS, () => { + req.destroy() + reject(new Error('微博请求超时')) + }) + + req.on('error', reject) + req.end() + }) +} + +function normalizeCookieArray(entries: BrowserCookieEntry[]): string { + const picked = new Map() + + for (const entry of entries) { + const name = String(entry?.name || '').trim() + const value = String(entry?.value || '').trim() + const domain = String(entry?.domain || '').trim().toLowerCase() + + if (!name || !value) continue + if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue + + picked.set(name, value) + } + + return Array.from(picked.entries()) + .map(([name, value]) => `${name}=${value}`) + .join('; ') +} + +export function normalizeWeiboCookieInput(rawInput: string): string { + const trimmed = String(rawInput || '').trim() + if (!trimmed) return '' + + try { + const parsed = JSON.parse(trimmed) as unknown + if (Array.isArray(parsed)) { + const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[]) + if (normalized) return normalized + throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项') + } + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error + } + } + + return trimmed.replace(/^Cookie:\s*/i, '').trim() +} + +function normalizeWeiboUid(input: string): string { + const trimmed = String(input || '').trim() + const directMatch = trimmed.match(/^\d{5,}$/) + if (directMatch) return directMatch[0] + + const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i) + if (linkMatch) return linkMatch[1] + + throw new Error('请输入有效的微博 UID(纯数字)') +} + +function sanitizeWeiboText(text: string): string { + return String(text || '') + .replace(/\u200b|\u200c|\u200d|\ufeff/g, '') + .replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ') + .replace(/ +/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +function mergeRetweetText(item: Pick): string { + const baseText = sanitizeWeiboText(item.text_raw || '') + const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '') + if (!retweetText) return baseText + if (!baseText || baseText === '转发微博') return `转发:${retweetText}` + return `${baseText}\n\n转发内容:${retweetText}` +} + +function buildCacheKey(uid: string, count: number, cookie: string): string { + const cookieHash = createHash('sha1').update(cookie).digest('hex') + return `${uid}:${count}:${cookieHash}` +} + +class WeiboService { + private recentPostsCache = new Map() + + clearCache(): void { + this.recentPostsCache.clear() + } + + async validateUid( + uidInput: string, + cookieInput: string + ): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> { + try { + const uid = normalizeWeiboUid(uidInput) + const cookie = normalizeWeiboCookieInput(cookieInput) + if (!cookie) { + return { success: true, uid } + } + + const timeline = await this.fetchTimeline(uid, cookie) + const firstItem = timeline.data?.list?.[0] + if (!firstItem) { + return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' } + } + + return { + success: true, + uid, + screenName: firstItem.user?.screen_name + } + } catch (error) { + return { + success: false, + error: (error as Error).message || '微博 UID 校验失败' + } + } + } + + async fetchRecentPosts( + uidInput: string, + cookieInput: string, + requestedCount: number + ): Promise { + const uid = normalizeWeiboUid(uidInput) + const cookie = normalizeWeiboCookieInput(cookieInput) + const hasCookie = Boolean(cookie) + + const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0))) + const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__') + const cached = this.recentPostsCache.get(cacheKey) + const now = Date.now() + + if (cached && cached.expiresAt > now) { + return cached.posts + } + + const rawItems = hasCookie + ? (await this.fetchTimeline(uid, cookie)).data?.list || [] + : await this.fetchMobileTimeline(uid) + const posts: WeiboRecentPost[] = [] + + for (const item of rawItems) { + if (posts.length >= count) break + + const id = String(item.idstr || item.id || '').trim() + if (!id) continue + + let text = mergeRetweetText(item) + if (item.isLongText && hasCookie) { + try { + const detail = await this.fetchDetail(id, cookie) + text = mergeRetweetText(detail) + } catch { + // 长文补抓失败时回退到列表摘要 + } + } + + text = sanitizeWeiboText(text) + if (!text) continue + + posts.push({ + id, + createdAt: String(item.created_at || ''), + url: `https://m.weibo.cn/detail/${id}`, + text, + screenName: item.user?.screen_name + }) + } + + this.recentPostsCache.set(cacheKey, { + expiresAt: now + WEIBO_CACHE_TTL_MS, + posts + }) + + return posts + } + + private fetchTimeline(uid: string, cookie: string): Promise { + return requestJson( + `https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`, + { + cookie, + referer: `https://weibo.com/u/${encodeURIComponent(uid)}` + } + ).then((response) => { + if (response.ok !== 1 || !Array.isArray(response.data?.list)) { + throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效') + } + return response + }) + } + + private fetchMobileTimeline(uid: string): Promise { + const containerid = `107603${uid}` + return requestJson( + `https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`, + { + referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`, + userAgent: WEIBO_MOBILE_USER_AGENT + } + ).then((response) => { + if (response.ok !== 1 || !Array.isArray(response.data?.cards)) { + throw new Error('微博时间线获取失败,请稍后重试') + } + + const rows: WeiboWaterFallItem[] = [] + for (const card of response.data.cards) { + if (card?.mblog) rows.push(card.mblog) + if (Array.isArray(card?.card_group)) { + for (const subCard of card.card_group) { + if (subCard?.mblog) rows.push(subCard.mblog) + } + } + } + + if (rows.length === 0) { + throw new Error('该微博账号暂无可读取的近期公开内容') + } + + return rows + }) + } + + private fetchDetail(id: string, cookie: string): Promise { + return requestJson( + `https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`, + { + cookie, + referer: `https://weibo.com/detail/${encodeURIComponent(id)}` + } + ).then((response) => { + if (!response || (!response.id && !response.idstr)) { + throw new Error('微博详情获取失败') + } + return response + }) + } +} + +export const weiboService = new WeiboService() diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index b108dec..4785621 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,8 +1,6 @@ import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs' -import { spawn } from 'child_process' +import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' import { pathToFileURL } from 'url' -import crypto from 'crypto' import { app } from 'electron' import { ConfigService } from './config' import { wcdbService } from './wcdbService' @@ -27,48 +25,15 @@ interface VideoIndexEntry { type PosterFormat = 'dataUrl' | 'fileUrl' -function getStaticFfmpegPath(): string | null { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const ffmpegStatic = require('ffmpeg-static') - if (typeof ffmpegStatic === 'string') { - let fixedPath = ffmpegStatic - if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) { - fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked') - } - if (existsSync(fixedPath)) return fixedPath - } - } catch { - // ignore - } - - const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' - const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName) - if (existsSync(devPath)) return devPath - - if (app.isPackaged) { - const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName) - if (existsSync(packedPath)) return packedPath - } - - return null -} - class VideoService { private configService: ConfigService private hardlinkResolveCache = new Map>() private videoInfoCache = new Map>() private videoDirIndexCache = new Map>>() private pendingVideoInfo = new Map>() - private pendingPosterExtract = new Map>() - private extractedPosterCache = new Map>() - private posterExtractRunning = 0 - private posterExtractQueue: Array<() => void> = [] private readonly hardlinkCacheTtlMs = 10 * 60 * 1000 private readonly videoInfoCacheTtlMs = 2 * 60 * 1000 private readonly videoIndexCacheTtlMs = 90 * 1000 - private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000 - private readonly maxPosterExtractConcurrency = 1 private readonly maxCacheEntries = 2000 private readonly maxIndexEntries = 6 @@ -287,11 +252,9 @@ class VideoService { } async preloadVideoHardlinkMd5s(md5List: string[]): Promise { - const dbPath = this.getDbPath() - const wxid = this.getMyWxid() - const cleanedWxid = this.cleanWxid(wxid) - if (!dbPath || !wxid) return - await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid) + // 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。 + // 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。 + void md5List } private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined { @@ -429,6 +392,23 @@ class VideoService { return null } + private normalizeVideoLookupKey(value: string): string { + let text = String(value || '').trim().toLowerCase() + if (!text) return '' + text = text.replace(/^.*[\\/]/, '') + text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '') + text = text.replace(/_thumb$/, '') + const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) + if (direct) { + const suffix = /_raw$/i.test(text) ? '_raw' : '' + return `${direct[1].toLowerCase()}${suffix}` + } + const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) + if (preferred32?.[1]) return preferred32[1].toLowerCase() + const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) + return String(fallback?.[1] || '').toLowerCase() + } + private fallbackScanVideo( videoBaseDir: string, realVideoMd5: string, @@ -473,154 +453,10 @@ class VideoService { return null } - private getFfmpegPath(): string { - const staticPath = getStaticFfmpegPath() - if (staticPath) return staticPath - return 'ffmpeg' - } - - private async withPosterExtractSlot(run: () => Promise): Promise { - if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) { - await new Promise((resolve) => { - this.posterExtractQueue.push(resolve) - }) - } - this.posterExtractRunning += 1 - try { - return await run() - } finally { - this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1) - const next = this.posterExtractQueue.shift() - if (next) next() - } - } - - private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise { - const normalizedPath = String(videoPath || '').trim() - if (!normalizedPath || !existsSync(normalizedPath)) return null - - const cacheKey = `${normalizedPath}|format=${posterFormat}` - const cached = this.readTimedCache(this.extractedPosterCache, cacheKey) - if (cached !== undefined) return cached - - const pending = this.pendingPosterExtract.get(cacheKey) - if (pending) return pending - - const task = this.withPosterExtractSlot(() => new Promise((resolve) => { - const tmpDir = join(app.getPath('temp'), 'weflow_video_frames') - try { - if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) - } catch { - resolve(null) - return - } - - const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24) - const outputPath = join(tmpDir, `frame_${stableHash}.jpg`) - if (posterFormat === 'fileUrl' && existsSync(outputPath)) { - resolve(pathToFileURL(outputPath).toString()) - return - } - - const ffmpegPath = this.getFfmpegPath() - const args = [ - '-hide_banner', '-loglevel', 'error', '-y', - '-ss', '0', - '-i', normalizedPath, - '-frames:v', '1', - '-q:v', '3', - outputPath - ] - - const errChunks: Buffer[] = [] - let done = false - const finish = (value: string | null) => { - if (done) return - done = true - if (posterFormat === 'dataUrl') { - try { - if (existsSync(outputPath)) unlinkSync(outputPath) - } catch { - // ignore - } - } - resolve(value) - } - - const proc = spawn(ffmpegPath, args, { - stdio: ['ignore', 'ignore', 'pipe'], - windowsHide: true - }) - - const timer = setTimeout(() => { - try { proc.kill('SIGKILL') } catch { /* ignore */ } - finish(null) - }, 12000) - - proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)) - - proc.on('error', () => { - clearTimeout(timer) - finish(null) - }) - - proc.on('close', (code: number) => { - clearTimeout(timer) - if (code !== 0 || !existsSync(outputPath)) { - if (errChunks.length > 0) { - this.log('extractFirstFrameDataUrl failed', { - videoPath: normalizedPath, - error: Buffer.concat(errChunks).toString().slice(0, 240) - }) - } - finish(null) - return - } - try { - const jpgBuf = readFileSync(outputPath) - if (!jpgBuf.length) { - finish(null) - return - } - if (posterFormat === 'fileUrl') { - finish(pathToFileURL(outputPath).toString()) - return - } - finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`) - } catch { - finish(null) - } - }) - })) - - this.pendingPosterExtract.set(cacheKey, task) - try { - const result = await task - this.writeTimedCache( - this.extractedPosterCache, - cacheKey, - result, - this.extractedPosterCacheTtlMs, - this.maxCacheEntries - ) - return result - } finally { - this.pendingPosterExtract.delete(cacheKey) - } - } - private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise { + void posterFormat if (!includePoster) return info - if (!info.exists || !info.videoUrl) return info - if (info.coverUrl || info.thumbUrl) return info - - const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat) - if (!extracted) return info - return { - ...info, - coverUrl: extracted, - thumbUrl: extracted - } + return info } /** @@ -652,7 +488,7 @@ class VideoService { if (pending) return pending const task = (async (): Promise => { - const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5 + const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5 const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid) if (!existsSync(videoBaseDir)) { @@ -678,7 +514,7 @@ class VideoService { const miss = { exists: false } this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries) - this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 }) + this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 }) return miss })() diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 116ba45..af797f7 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() @@ -2007,6 +2011,14 @@ export class WcdbCore { } return '' } + const pickRaw = (row: Record, keys: string[]): unknown => { + for (const key of keys) { + const value = row[key] + if (value === null || value === undefined) continue + return value + } + return undefined + } const extractXmlValue = (xml: string, tag: string): string => { if (!xml) return '' const regex = new RegExp(`<${tag}>([\\s\\S]*?)`, 'i') @@ -2092,25 +2104,37 @@ export class WcdbCore { const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase) return String(md5Like?.[1] || fileBase || '').trim().toLowerCase() } - const decodePackedToPrintable = (raw: string): string => { - const text = String(raw || '').trim() - if (!text) return '' - let buf: Buffer | null = null - if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) { - try { - buf = Buffer.from(text, 'hex') - } catch { - buf = null + const decodePackedInfoBuffer = (raw: unknown): Buffer | null => { + if (!raw) return null + if (Buffer.isBuffer(raw)) return raw + if (raw instanceof Uint8Array) return Buffer.from(raw) + if (Array.isArray(raw)) return Buffer.from(raw as any[]) + if (typeof raw === 'string') { + const text = raw.trim() + if (!text) return null + const compactHex = text.replace(/\s+/g, '') + if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) { + try { + return Buffer.from(compactHex, 'hex') + } catch { + // ignore + } } - } - if (!buf) { try { const base64 = Buffer.from(text, 'base64') - if (base64.length > 0) buf = base64 + if (base64.length > 0) return base64 } catch { - buf = null + // ignore } + return null } + if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) { + return Buffer.from((raw as any).data) + } + return null + } + const decodePackedToPrintable = (raw: unknown): string => { + const buf = decodePackedInfoBuffer(raw) if (!buf || buf.length === 0) return '' const printable: number[] = [] for (const byte of buf) { @@ -2125,6 +2149,46 @@ export class WcdbCore { const match = /([a-fA-F0-9]{32})/.exec(input) return String(match?.[1] || '').toLowerCase() } + const normalizeVideoFileToken = (value: unknown): string => { + let text = String(value || '').trim().toLowerCase() + if (!text) return '' + text = text.replace(/^.*[\\/]/, '') + text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '') + text = text.replace(/_thumb$/, '') + const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text) + if (direct) { + const suffix = /_raw$/i.test(text) ? '_raw' : '' + return `${direct[1].toLowerCase()}${suffix}` + } + const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text) + if (preferred32?.[1]) return preferred32[1].toLowerCase() + const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text) + return String(fallback?.[1] || '').toLowerCase() + } + const extractVideoFileNameFromPackedRaw = (raw: unknown): string => { + const buf = decodePackedInfoBuffer(raw) + if (!buf || buf.length === 0) return '' + const candidates: string[] = [] + let current = '' + for (const byte of buf) { + const isHex = + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x46) || + (byte >= 0x61 && byte <= 0x66) + if (isHex) { + current += String.fromCharCode(byte) + continue + } + if (current.length >= 16) candidates.push(current) + current = '' + } + if (current.length >= 16) candidates.push(current) + if (candidates.length === 0) return '' + const exact32 = candidates.find((item) => item.length === 32) + if (exact32) return exact32.toLowerCase() + const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64) + return String(fallback || '').toLowerCase() + } const extractImageDatName = (row: Record, content: string): string => { const direct = pickString(row, [ 'image_path', @@ -2143,7 +2207,7 @@ export class WcdbCore { const normalizedXml = normalizeDatBase(xmlCandidate) if (normalizedXml) return normalizedXml - const packedRaw = pickString(row, [ + const packedRaw = pickRaw(row, [ 'packed_info_data', 'packedInfoData', 'packed_info_blob', @@ -2168,7 +2232,7 @@ export class WcdbCore { return '' } const extractPackedPayload = (row: Record): string => { - const packedRaw = pickString(row, [ + const packedRaw = pickRaw(row, [ 'packed_info_data', 'packedInfoData', 'packed_info_blob', @@ -2323,6 +2387,20 @@ export class WcdbCore { const packedPayload = extractPackedPayload(row) const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5']) const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5']) + const packedRaw = pickRaw(row, [ + 'packed_info_data', + 'packedInfoData', + 'packed_info_blob', + 'packedInfoBlob', + 'packed_info', + 'packedInfo', + 'BytesExtra', + 'bytes_extra', + 'WCDB_CT_packed_info', + 'reserved0', + 'Reserved0', + 'WCDB_CT_Reserved0' + ]) let content = '' let imageMd5: string | undefined @@ -2338,10 +2416,17 @@ export class WcdbCore { if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined } } else if (localType === 43) { - videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined + videoMd5 = + extractVideoFileNameFromPackedRaw(packedRaw) || + normalizeVideoFileToken(videoMd5ByColumn) || + extractHexMd5(packedPayload) || + undefined if (!videoMd5) { content = decodeContentIfNeeded() - videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined + videoMd5 = + normalizeVideoFileToken(extractVideoMd5(content)) || + extractHexMd5(packedPayload) || + undefined } else if (useRawMessageContent) { // 占位态标题只依赖简单 XML,已带 md5 时不做额外解压 content = rawMessageContent 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 dfc882d..7c6f375 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": [ @@ -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/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 9a7ee16..adb7be1 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react' +import React, { useState, useMemo, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react' import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns' @@ -8,6 +8,7 @@ import { getEmojiPath } from 'wechat-emojis' // Helper functions (extracted from SnsPage.tsx but simplified/reused) const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl'] +const LINK_XML_DIRECT_URL_TAGS = ['contentUrl', ...LINK_XML_URL_TAGS] const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle'] const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload'] @@ -29,6 +30,13 @@ const decodeHtmlEntities = (text: string): string => { .trim() } +const normalizeRawXmlForParsing = (xml: string): string => { + if (!xml) return '' + return decodeHtmlEntities(xml) + .replace(/\\+"/g, '"') + .replace(/\\+'/g, "'") +} + const normalizeUrlCandidate = (raw: string): string | null => { const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim() if (!value) return null @@ -43,12 +51,13 @@ const simplifyUrlForCompare = (value: string): string => { } const getXmlTagValues = (xml: string, tags: string[]): string[] => { - if (!xml) return [] + const normalizedXml = normalizeRawXmlForParsing(xml) + if (!normalizedXml) return [] const results: string[] = [] for (const tag of tags) { const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig') let match: RegExpExecArray | null - while ((match = reg.exec(xml)) !== null) { + while ((match = reg.exec(normalizedXml)) !== null) { if (match[1]) results.push(match[1]) } } @@ -65,20 +74,87 @@ const isLikelyMediaAssetUrl = (url: string): boolean => { return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint)) } +const normalizeSnsAssetUrl = (url: string, token?: string, encIdx?: string): string => { + const base = decodeHtmlEntities(url).trim() + if (!base) return '' + + let fixed = base.replace(/^http:\/\//i, 'https://') + + const normalizedToken = decodeHtmlEntities(String(token || '')).trim() + const normalizedEncIdx = decodeHtmlEntities(String(encIdx || '')).trim() + const effectiveIdx = normalizedEncIdx || (normalizedToken ? '1' : '') + const appendParams: string[] = [] + if (normalizedToken && !/[?&]token=/i.test(fixed)) { + appendParams.push(`token=${normalizedToken}`) + } + if (effectiveIdx && !/[?&]idx=/i.test(fixed)) { + appendParams.push(`idx=${effectiveIdx}`) + } + if (appendParams.length > 0) { + const connector = fixed.includes('?') ? '&' : '?' + fixed = `${fixed}${connector}${appendParams.join('&')}` + } + return fixed +} + +const extractCardThumbMetaFromXml = (xml: string): { thumb?: string; thumbKey?: string } => { + const normalizedXml = normalizeRawXmlForParsing(xml) + if (!normalizedXml) return {} + const mediaMatch = normalizedXml.match(/([\s\S]*?)<\/media>/i) + if (!mediaMatch?.[1]) return {} + + const mediaXml = mediaMatch[1] + const thumbMatch = mediaXml.match(/]*)>([^<]+)<\/thumb>/i) + if (!thumbMatch) return {} + + const attrs = thumbMatch[1] || '' + const getAttr = (name: string): string | undefined => { + const reg = new RegExp(`${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i') + const m = attrs.match(reg) + return decodeHtmlEntities((m?.[1] || m?.[2] || m?.[3] || '').trim()) || undefined + } + const thumbRawUrl = thumbMatch[2] || '' + const thumbToken = getAttr('token') + const thumbKey = getAttr('key') + const thumbEncIdx = getAttr('enc_idx') + const thumb = normalizeSnsAssetUrl(thumbRawUrl, thumbToken, thumbEncIdx) + + return { + thumb: thumb || undefined, + thumbKey: thumbKey ? decodeHtmlEntities(thumbKey).trim() : undefined + } +} + +const pickCardTitle = (post: SnsPost): string => { + const titleCandidates = [ + post.linkTitle || '', + ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), + post.contentDesc || '' + ] + return titleCandidates + .map((value) => decodeHtmlEntities(value)) + .find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) || '网页链接' +} + const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => { - // type 3 是链接类型,直接用 media[0] 的 url 和 thumb - if (post.type === 3) { - const url = post.media[0]?.url || post.linkUrl - if (!url) return null - const titleCandidates = [ - post.linkTitle || '', - ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), - post.contentDesc || '' + // type 3 / 5 是链接卡片类型,优先按卡片链接解析 + if (post.type === 3 || post.type === 5) { + const thumbMeta = extractCardThumbMetaFromXml(post.rawXml || '') + const directUrlCandidates = [ + post.linkUrl || '', + ...getXmlTagValues(post.rawXml || '', LINK_XML_DIRECT_URL_TAGS), + ...post.media.map((item) => item.url || '') ] - const title = titleCandidates - .map((v) => decodeHtmlEntities(v)) - .find((v) => Boolean(v) && !/^https?:\/\//i.test(v)) - return { url, title: title || '网页链接', thumb: post.media[0]?.thumb } + const url = directUrlCandidates + .map(normalizeUrlCandidate) + .find((value): value is string => Boolean(value)) + if (!url) return null + return { + url, + title: pickCardTitle(post), + thumb: thumbMeta.thumb || post.media[0]?.thumb || post.media[0]?.url, + thumbKey: thumbMeta.thumbKey || post.media[0]?.key + } } const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url)) @@ -117,19 +193,9 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => { if (!linkUrl) return null - const titleCandidates = [ - post.linkTitle || '', - ...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS), - post.contentDesc || '' - ] - - const title = titleCandidates - .map((value) => decodeHtmlEntities(value)) - .find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) - return { url: linkUrl, - title: title || '网页链接', + title: pickCardTitle(post), thumb: post.media[0]?.thumb || post.media[0]?.url } } @@ -158,8 +224,11 @@ const buildLocationText = (location?: SnsLocation): string => { return primary || region } -const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { +const SnsLinkCard = ({ card, thumbKey }: { card: SnsLinkCardData; thumbKey?: string }) => { const [thumbFailed, setThumbFailed] = useState(false) + const [thumbSrc, setThumbSrc] = useState(card.thumb || '') + const [reloadNonce, setReloadNonce] = useState(0) + const retryCountRef = useRef(0) const hostname = useMemo(() => { try { return new URL(card.url).hostname.replace(/^www\./i, '') @@ -168,6 +237,58 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { } }, [card.url]) + useEffect(() => { + retryCountRef.current = 0 + }, [card.thumb, thumbKey]) + + const scheduleRetry = () => { + if (retryCountRef.current >= 2) return + retryCountRef.current += 1 + window.setTimeout(() => { + setReloadNonce((v) => v + 1) + }, 900) + } + + useEffect(() => { + const rawThumb = card.thumb || '' + setThumbFailed(false) + setThumbSrc(rawThumb) + if (!rawThumb) return + + let cancelled = false + const loadThumb = async () => { + try { + const result = await window.electronAPI.sns.proxyImage({ + url: rawThumb, + key: thumbKey + }) + if (cancelled) return + if (!result.success) { + console.warn('[SnsLinkCard] thumb decrypt failed', { + url: rawThumb, + key: thumbKey, + error: result.error + }) + scheduleRetry() + return + } + if (result.dataUrl) { + setThumbSrc(result.dataUrl) + return + } + if (result.videoPath) { + setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`) + } + } catch { + // noop: keep raw thumb fallback + scheduleRetry() + } + } + + loadThumb() + return () => { cancelled = true } + }, [card.thumb, thumbKey, reloadNonce]) + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation() try { @@ -180,13 +301,31 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => { return (
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? ( -
- {sessionDetail.messageTables.map((t, i) => ( -
- {t.dbName} - {t.count.toLocaleString()} 条 -
- ))} -
+ <> +
+ 表名 + + {(() => { + const tableNames = Array.from(new Set( + sessionDetail.messageTables + .map(item => String(item.tableName || '').trim()) + .filter(Boolean) + )) + return tableNames[0] || '—' + })()} + +
+
+ {sessionDetail.messageTables.map((t, i) => ( +
+ {t.dbName || '—'} + {t.count.toLocaleString()} 条 +
+ ))} +
+ ) : (
{isLoadingDetailExtra ? '统计中...' : '暂无统计数据'} @@ -7309,8 +7563,8 @@ function ChatPage(props: ChatPageProps) { {batchVoiceTaskType === 'decrypt' - ? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。' - : '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'} + ? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能,进度会写入导出页任务中心。' + : '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过,进度会写入导出页任务中心。'}
@@ -7377,17 +7631,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 +8022,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 +8142,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 +8164,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 +8521,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 +8566,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 +8621,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 +8652,7 @@ function MessageBubble({ lockImageStageHeight, message.imageDatName, message.imageMd5, + message.createTime, requestImageDecrypt, session.username ]) @@ -8391,8 +8666,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 +8687,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 +8695,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 +8719,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 +8747,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 +9387,7 @@ function MessageBubble({ <>
图片 createdAt: number startedAt?: number finishedAt?: number @@ -209,6 +213,8 @@ interface AutomationTaskDraft { dateRangeConfig: ExportAutomationDateRangeConfig | string | null intervalDays: number intervalHours: number + firstTriggerAtEnabled: boolean + firstTriggerAtValue: string stopAtEnabled: boolean stopAtValue: string maxRunsEnabled: boolean @@ -218,6 +224,7 @@ interface AutomationTaskDraft { const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900 +const EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS = 320 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 @@ -249,6 +256,8 @@ const backgroundTaskSourceLabels: Record = { const backgroundTaskStatusLabels: Record = { running: '运行中', + pause_requested: '中断中', + paused: '已中断', cancel_requested: '停止中', completed: '已完成', failed: '失败', @@ -322,6 +331,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 +408,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 +581,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() @@ -544,6 +654,32 @@ const formatPathBrief = (value: string, maxLength = 52): string => { return `${normalized.slice(0, headLength)}…${normalized.slice(-tailLength)}` } +const resolveParentDir = (value: string): string => { + const normalized = String(value || '').trim() + if (!normalized) return '' + const noTrailing = normalized.replace(/[\\/]+$/, '') + if (!noTrailing) return normalized + const lastSlash = Math.max(noTrailing.lastIndexOf('/'), noTrailing.lastIndexOf('\\')) + if (lastSlash < 0) return normalized + if (lastSlash === 0) return noTrailing.slice(0, 1) + if (/^[A-Za-z]:$/.test(noTrailing.slice(0, lastSlash))) { + return `${noTrailing.slice(0, lastSlash)}\\` + } + return noTrailing.slice(0, lastSlash) +} + +const resolveTaskOpenDir = (task: ExportTask): string => { + const sessionIds = Array.isArray(task.payload.sessionIds) ? task.payload.sessionIds : [] + if (sessionIds.length === 1) { + const onlySessionId = String(sessionIds[0] || '').trim() + const outputPath = onlySessionId ? String(task.sessionOutputPaths?.[onlySessionId] || '').trim() : '' + if (outputPath) { + return resolveParentDir(outputPath) || task.payload.outputDir + } + } + return task.payload.outputDir +} + const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { if (!timestamp) return '' const diff = Math.max(0, now - timestamp) @@ -635,6 +771,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 +785,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 +808,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 +1002,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 +1788,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 +1919,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 +1966,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
)} + {imageTimingLabel && task.status !== 'queued' && ( +
+ {imageTimingLabel} +
+ )} {canShowPerfDetail && stageTotals && (
累计耗时 {formatDurationMs(stageTotalMs)} @@ -1795,13 +2032,84 @@ const TaskCenterModal = memo(function TaskCenterModal({ {isPerfExpanded ? '收起详情' : '性能详情'} )} -
) })} + {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 +2211,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 +2231,7 @@ function ExportPage() { excelCompactColumns: true, txtColumns: defaultTxtColumns, displayNamePreference: 'remark', - exportConcurrency: 2, - imageDeepSearchOnMiss: true + exportConcurrency: 2 }) const [exportDialog, setExportDialog] = useState({ @@ -2622,7 +2928,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 +2937,6 @@ function ExportPage() { configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultTxtColumns(), configService.getExportDefaultConcurrency(), - configService.getExportDefaultImageDeepSearchOnMiss(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), configService.getExportSessionRecordMap(), @@ -2671,7 +2976,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 +3013,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 +4794,7 @@ function ExportPage() { maxFileSizeMb: prev.maxFileSizeMb, exportVoiceAsText: exportDefaultVoiceAsText, excelCompactColumns: exportDefaultExcelCompactColumns, - exportConcurrency: exportDefaultConcurrency, - imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss + exportConcurrency: exportDefaultConcurrency } if (payload.scope === 'sns') { @@ -4527,8 +4829,7 @@ function ExportPage() { exportDefaultAvatars, exportDefaultMedia, exportDefaultVoiceAsText, - exportDefaultConcurrency, - exportDefaultImageDeepSearchOnMiss + exportDefaultConcurrency ]) const closeExportDialog = useCallback(() => { @@ -4755,7 +5056,6 @@ function ExportPage() { txtColumns: options.txtColumns, displayNamePreference: options.displayNamePreference, exportConcurrency: options.exportConcurrency, - imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, fileNamingMode: exportDefaultFileNamingMode, sessionLayout, sessionNameWithTypePrefix, @@ -4834,6 +5134,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 +5155,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 +5262,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 +5460,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 +5515,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 +5572,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 { @@ -5444,6 +5749,12 @@ function ExportPage() { ...task, status: 'success', finishedAt: doneAt, + sessionOutputPaths: { + ...(task.sessionOutputPaths || {}), + ...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object') + ? result.sessionOutputPaths + : {}) + }, progress: { ...task.progress, current: task.progress.total || next.payload.sessionIds.length, @@ -5656,6 +5967,8 @@ function ExportPage() { dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection), intervalDays: 1, intervalHours: 0, + firstTriggerAtEnabled: false, + firstTriggerAtValue: '', stopAtEnabled: false, stopAtValue: '', maxRunsEnabled: false, @@ -5691,8 +6004,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 +7647,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 +7716,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 +7811,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 +7837,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 +8475,16 @@ function ExportPage() { {isAutomationModalOpen && createPortal( @@ -8220,6 +8560,7 @@ function ExportPage() { {queueState === 'queued' && 排队中}

{formatAutomationScheduleLabel(task.schedule)}

+

首次触发:{resolveAutomationFirstTriggerSummary(task)}

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

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

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

@@ -8333,6 +8674,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 +8860,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 +9337,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 +10106,6 @@ function ExportPage() {
)} - {shouldRenderImageDeepSearchToggle && ( -
-
-
-
-
-

缺图时深度搜索

-
关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。
-
- -
-
-
-
- )} - {isSessionScopeDialog && (
diff --git a/src/pages/ResourcesPage.scss b/src/pages/ResourcesPage.scss index cdbd17c..920fe3f 100644 --- a/src/pages/ResourcesPage.scss +++ b/src/pages/ResourcesPage.scss @@ -281,10 +281,10 @@ } } + .floating-info, .floating-delete { position: absolute; top: 10px; - right: 10px; z-index: 4; width: 28px; height: 28px; @@ -302,6 +302,18 @@ transition: opacity 0.16s ease, transform 0.16s ease; } + .floating-info { + right: 10px; + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--text-primary); + } + + .floating-delete { + right: 44px; + } + + .media-card:hover .floating-info, + .media-card:focus-within .floating-info, .media-card:hover .floating-delete, .media-card:focus-within .floating-delete { opacity: 1; @@ -490,7 +502,9 @@ .resource-dialog-mask { position: absolute; inset: 0; - background: rgba(8, 11, 18, 0.24); + background: rgba(8, 11, 18, 0.46); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; @@ -498,11 +512,13 @@ } .resource-dialog { + --dialog-surface: color-mix(in srgb, var(--bg-primary, #ffffff) 82%, var(--card-inner-bg, #ffffff) 18%); + --dialog-surface-header: color-mix(in srgb, var(--dialog-surface) 90%, var(--bg-secondary, #ffffff) 10%); width: min(420px, calc(100% - 32px)); - background: var(--card-bg, #ffffff); + background: var(--dialog-surface); border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); border-radius: 14px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.22); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); overflow: hidden; } @@ -512,7 +528,7 @@ font-weight: 600; color: var(--text-primary); border-bottom: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); - background: color-mix(in srgb, var(--bg-secondary) 85%, transparent); + background: var(--dialog-surface-header); } .dialog-body { @@ -521,6 +537,34 @@ color: var(--text-secondary); line-height: 1.55; white-space: pre-wrap; + background: var(--dialog-surface); + } + + .dialog-info-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .dialog-info-row { + display: grid; + grid-template-columns: 110px 1fr; + gap: 10px; + align-items: flex-start; + } + + .info-label { + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + } + + .info-value { + color: var(--text-primary); + line-height: 1.5; + word-break: break-all; + white-space: pre-wrap; + user-select: text; } .dialog-actions { @@ -528,6 +572,7 @@ display: flex; justify-content: flex-end; gap: 8px; + background: var(--dialog-surface); } .dialog-btn { diff --git a/src/pages/ResourcesPage.tsx b/src/pages/ResourcesPage.tsx index 7518647..5e2dedc 100644 --- a/src/pages/ResourcesPage.tsx +++ b/src/pages/ResourcesPage.tsx @@ -1,5 +1,5 @@ import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react' -import { Calendar, Image as ImageIcon, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react' +import { Calendar, Image as ImageIcon, Info, Loader2, PlayCircle, RefreshCw, Trash2, UserRound } from 'lucide-react' import { VirtuosoGrid } from 'react-virtuoso' import { finishBackgroundTask, registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' import './ResourcesPage.scss' @@ -28,9 +28,10 @@ interface ContactOption { } type DialogState = { - mode: 'alert' | 'confirm' + mode: 'alert' | 'confirm' | 'info' title: string - message: string + message?: string + infoRows?: Array<{ label: string; value: string }> confirmText?: string cancelText?: string onConfirm?: (() => void) | null @@ -44,6 +45,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 +73,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 +100,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}` } @@ -100,6 +116,12 @@ function formatTimeLabel(timestampSec: number): string { }) } +function formatInfoValue(value: unknown): string { + if (value === null || value === undefined) return '-' + const text = String(value).trim() + return text || '-' +} + function extractVideoTitle(content?: string): string { const xml = String(content || '') if (!xml) return '视频' @@ -137,6 +159,7 @@ const MediaCard = memo(function MediaCard({ decrypting, onToggleSelect, onDelete, + onShowInfo, onImagePreviewAction, onUpdateImageQuality, onOpenVideo, @@ -152,6 +175,7 @@ const MediaCard = memo(function MediaCard({ decrypting: boolean onToggleSelect: (item: MediaStreamItem) => void onDelete: (item: MediaStreamItem) => void + onShowInfo: (item: MediaStreamItem) => void onImagePreviewAction: (item: MediaStreamItem) => void onUpdateImageQuality: (item: MediaStreamItem) => void onOpenVideo: (item: MediaStreamItem) => void @@ -163,6 +187,9 @@ const MediaCard = memo(function MediaCard({ return (
+ @@ -658,19 +685,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 +714,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 +764,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]) @@ -776,6 +808,93 @@ function ResourcesPage() { return md5 }, []) + const showMediaInfo = useCallback(async (item: MediaStreamItem) => { + const itemKey = getItemKey(item) + const mediaLabel = item.mediaType === 'image' ? '图片' : '视频' + const baseRows: Array<{ label: string; value: string }> = [ + { label: '资源类型', value: mediaLabel }, + { label: '会话 ID', value: formatInfoValue(item.sessionId) }, + { label: '消息 LocalId', value: formatInfoValue(item.localId) }, + { label: '消息时间', value: formatTimeLabel(item.createTime) }, + { label: '发送方', value: formatInfoValue(item.senderUsername) }, + { label: '是否我发送', value: item.isSend === 1 ? '是' : (item.isSend === 0 ? '否' : '-') } + ] + + setDialog({ + mode: 'info', + title: `${mediaLabel}信息`, + infoRows: [...baseRows, { label: '状态', value: '正在读取缓存信息...' }], + confirmText: '关闭', + onConfirm: null + }) + + try { + if (item.mediaType === 'image') { + const resolved = await window.electronAPI.image.resolveCache({ + sessionId: item.sessionId, + imageMd5: normalizeMediaToken(item.imageMd5) || undefined, + imageDatName: getSafeImageDatName(item) || undefined, + createTime: Number(item.createTime || 0) || undefined, + preferFilePath: true, + hardlinkOnly: true, + allowCacheIndex: true, + suppressEvents: true + }) + const previewPath = previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey] || '' + const cachePath = String(resolved?.localPath || previewPath || '').trim() + const rows: Array<{ label: string; value: string }> = [ + ...baseRows, + { label: 'imageMd5', value: formatInfoValue(normalizeMediaToken(item.imageMd5)) }, + { label: 'imageDatName', value: formatInfoValue(getSafeImageDatName(item)) }, + { label: '列表预览路径', value: formatInfoValue(previewPath) }, + { label: '缓存命中', value: resolved?.success && cachePath ? '是' : '否' }, + { label: '缓存路径', value: formatInfoValue(cachePath) }, + { label: '缓存可更新', value: resolved?.hasUpdate ? '是' : '否' }, + { label: '缓存状态', value: resolved?.success ? '可用' : formatInfoValue(resolved?.error || resolved?.failureKind || '未命中') } + ] + setDialog({ + mode: 'info', + title: '图片信息', + infoRows: rows, + confirmText: '关闭', + onConfirm: null + }) + return + } + + const resolvedMd5 = await resolveItemVideoMd5(item) + const videoInfo = resolvedMd5 + ? await window.electronAPI.video.getVideoInfo(resolvedMd5, { includePoster: true, posterFormat: 'fileUrl' }) + : null + const posterPath = videoPosterMapRef.current[itemKey] || posterPatchRef.current[itemKey] || '' + const rows: Array<{ label: string; value: string }> = [ + ...baseRows, + { label: 'videoMd5(消息)', value: formatInfoValue(normalizeMediaToken(item.videoMd5)) }, + { label: 'videoMd5(解析)', value: formatInfoValue(resolvedMd5) }, + { label: '视频文件存在', value: videoInfo?.success && videoInfo.exists ? '是' : '否' }, + { label: '视频路径', value: formatInfoValue(videoInfo?.videoUrl) }, + { label: '同名封面路径', value: formatInfoValue(videoInfo?.coverUrl) }, + { label: '列表封面路径', value: formatInfoValue(posterPath) }, + { label: '视频状态', value: videoInfo?.success ? '可用' : formatInfoValue(videoInfo?.error || '未找到') } + ] + setDialog({ + mode: 'info', + title: '视频信息', + infoRows: rows, + confirmText: '关闭', + onConfirm: null + }) + } catch (e) { + setDialog({ + mode: 'info', + title: `${mediaLabel}信息`, + infoRows: [...baseRows, { label: '读取失败', value: formatInfoValue(String(e)) }], + confirmText: '关闭', + onConfirm: null + }) + } + }, [resolveItemVideoMd5]) + const resolveVideoPoster = useCallback(async (item: MediaStreamItem) => { if (item.mediaType !== 'video') return const itemKey = getItemKey(item) @@ -795,7 +914,7 @@ function ResourcesPage() { attemptedVideoPosterKeysRef.current.add(itemKey) return } - const poster = String(info.coverUrl || info.thumbUrl || '') + const poster = String(info.coverUrl || '') if (!poster) { attemptedVideoPosterKeysRef.current.add(itemKey) return @@ -954,11 +1073,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 +1094,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 +1122,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 +1143,7 @@ function ResourcesPage() { setActionMessage('图片解密完成') return undefined } catch (e) { - showAlert(`解密失败:${String(e)}`, '解密失败') + showAlert(`本地无数据:${String(e)}`, '未找到本地数据') return undefined } finally { setDecryptingKeys((prev) => { @@ -1027,8 +1163,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 +1187,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 +1223,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 +1252,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 +1325,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', { @@ -1284,6 +1470,7 @@ function ResourcesPage() { decrypting={decryptingKeys.has(itemKey)} onToggleSelect={toggleSelect} onDelete={deleteOne} + onShowInfo={showMediaInfo} onImagePreviewAction={onImagePreviewAction} onUpdateImageQuality={updateImageQuality} onOpenVideo={openVideo} @@ -1301,7 +1488,20 @@ function ResourcesPage() {
{dialog.title}
-
{dialog.message}
+
+ {dialog.mode === 'info' ? ( +
+ {(dialog.infoRows || []).map((row, idx) => ( +
+ {row.label} + {row.value} +
+ ))} +
+ ) : ( + dialog.message + )} +
{dialog.mode === 'confirm' && (
+
) @@ -2331,6 +2360,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
+ ) const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || '' @@ -2438,6 +2468,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { + ) @@ -2819,6 +2850,28 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { /> +
+ + + 设置单次请求的最大输出 token 数量,见解与足迹共享该值。默认 200。 + + { + const parsed = parseInt(e.target.value, 10) + const val = Math.min(65535, Math.max(1, Number.isFinite(parsed) ? parsed : 200)) + setAiModelApiMaxTokens(val) + scheduleConfigSave('aiModelApiMaxTokens', () => configService.setAiModelApiMaxTokens(val)) + }} + style={{ width: 260 }} + /> +
+
@@ -2844,9 +2897,145 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { )}
+ ) + const withAsyncTimeout = async (task: Promise, timeoutMs: number, timeoutMessage: string): Promise => { + let timeoutHandle: ReturnType | null = null + try { + return await Promise.race([ + task, + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs) + }) + ]) + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle) + } + } + + const hasWeiboCookieConfigured = aiInsightWeiboCookie.trim().length > 0 + + const openWeiboCookieModal = () => { + setWeiboCookieDraft(aiInsightWeiboCookie) + setWeiboCookieError('') + setShowWeiboCookieModal(true) + } + + const persistWeiboCookieDraft = async (draftOverride?: string): Promise => { + const draftToSave = draftOverride ?? weiboCookieDraft + if (draftToSave === aiInsightWeiboCookie) return true + setIsSavingWeiboCookie(true) + setWeiboCookieError('') + try { + const result = await withAsyncTimeout( + window.electronAPI.social.saveWeiboCookie(draftToSave), + 10000, + '保存微博 Cookie 超时,请稍后重试' + ) + if (!result.success) { + setWeiboCookieError(result.error || '微博 Cookie 保存失败') + return false + } + const normalized = result.normalized || '' + setAiInsightWeiboCookie(normalized) + setWeiboCookieDraft(normalized) + showMessage(result.hasCookie ? '微博 Cookie 已保存' : '微博 Cookie 已清空', true) + return true + } catch (e: any) { + setWeiboCookieError(e?.message || String(e)) + return false + } finally { + setIsSavingWeiboCookie(false) + } + } + + const handleCloseWeiboCookieModal = async (discard = false) => { + if (discard) { + setShowWeiboCookieModal(false) + setWeiboCookieDraft(aiInsightWeiboCookie) + setWeiboCookieError('') + return + } + const ok = await persistWeiboCookieDraft() + if (!ok) return + setShowWeiboCookieModal(false) + setWeiboCookieError('') + } + + const getWeiboBindingDraftValue = (sessionId: string): string => { + const draft = weiboBindingDrafts[sessionId] + if (draft !== undefined) return draft + return aiInsightWeiboBindings[sessionId]?.uid || '' + } + + const updateWeiboBindingDraft = (sessionId: string, value: string) => { + setWeiboBindingDrafts((prev) => ({ + ...prev, + [sessionId]: value + })) + setWeiboBindingErrors((prev) => { + if (!prev[sessionId]) return prev + const next = { ...prev } + delete next[sessionId] + return next + }) + } + + const handleSaveWeiboBinding = async (sessionId: string, displayName: string) => { + const draftUid = getWeiboBindingDraftValue(sessionId) + setWeiboBindingLoadingSessionId(sessionId) + setWeiboBindingErrors((prev) => { + if (!prev[sessionId]) return prev + const next = { ...prev } + delete next[sessionId] + return next + }) + try { + const result = await withAsyncTimeout( + window.electronAPI.social.validateWeiboUid(draftUid), + 12000, + '微博 UID 校验超时,请稍后重试' + ) + if (!result.success || !result.uid) { + setWeiboBindingErrors((prev) => ({ ...prev, [sessionId]: result.error || '微博 UID 校验失败' })) + return + } + + const nextBindings: Record = { + ...aiInsightWeiboBindings, + [sessionId]: { + uid: result.uid, + screenName: result.screenName, + updatedAt: Date.now() + } + } + setAiInsightWeiboBindings(nextBindings) + await configService.setAiInsightWeiboBindings(nextBindings) + setWeiboBindingDrafts((prev) => ({ ...prev, [sessionId]: result.uid! })) + showMessage(`已为「${displayName}」绑定微博 UID`, true) + } catch (e: any) { + setWeiboBindingErrors((prev) => ({ ...prev, [sessionId]: e?.message || String(e) })) + } finally { + setWeiboBindingLoadingSessionId(null) + } + } + + const handleClearWeiboBinding = async (sessionId: string, silent = false) => { + const nextBindings = { ...aiInsightWeiboBindings } + delete nextBindings[sessionId] + setAiInsightWeiboBindings(nextBindings) + setWeiboBindingDrafts((prev) => ({ ...prev, [sessionId]: '' })) + setWeiboBindingErrors((prev) => { + if (!prev[sessionId]) return prev + const next = { ...prev } + delete next[sessionId] + return next + }) + await configService.setAiInsightWeiboBindings(nextBindings) + if (!silent) showMessage('已清除微博绑定', true) + } const renderInsightTab = () => (
{/* 总开关 */} @@ -2878,7 +3067,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- 该功能依赖「AI 通用」里的模型配置。用于验证完整链路(数据库→API→弹窗)。 + 该功能依赖「基础配置」里的模型配置。用于验证完整链路(数据库→API→弹窗)。
+ +
+
+ {!hasWeiboCookieConfigured && ( + + 未配置微博 Cookie 时,也会尝试抓取微博公开内容;但可能因平台风控导致获取失败或内容较少。 + + )} +
+ + {aiInsightAllowSocialContext && ( +
+ + + 当前仅支持微博最近发帖。 +
+ 不建议超过 5,避免触发平台风控。 +
+ { + const val = Math.max(1, Math.min(5, parseInt(e.target.value, 10) || 3)) + setAiInsightSocialContextCount(val) + scheduleConfigSave('aiInsightSocialContextCount', () => configService.setAiInsightSocialContextCount(val)) + }} + style={{ width: 100 }} + /> +
+ )} + +
{/* 自定义 System Prompt */} {(() => { const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。 @@ -3050,8 +3299,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
+ {weiboBinding && ( + + )} +
+
+ {weiboBindingError ? ( + {weiboBindingError} + ) : weiboBinding?.screenName ? ( + @{weiboBinding.screenName} + ) : weiboBinding?.uid ? ( + 已绑定 UID:{weiboBinding.uid} + ) : ( + 仅支持手动填写数字 UID + )} +
+