diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index 6c3c813..428aa14 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -60,7 +60,23 @@ jobs: fi gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH" RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')" - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null + RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after create:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi dev-mac-arm64: needs: prepare @@ -81,6 +97,22 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure mac key helpers are executable + shell: bash + run: | + set -euo pipefail + for file in \ + resources/key/macos/universal/xkey_helper \ + resources/key/macos/universal/image_scan_helper \ + resources/key/macos/universal/xkey_helper_macos \ + resources/key/macos/universal/libwx_key.dylib + do + if [ -f "$file" ]; then + chmod +x "$file" + ls -l "$file" + fi + done + - name: Set dev version shell: bash run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version @@ -270,21 +302,25 @@ jobs: - name: Update fixed dev release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }} shell: bash run: | set -euo pipefail - TAG="$FIXED_DEV_TAG" + TAG="${FIXED_DEV_TAG:-}" + if [ -z "$TAG" ]; then + echo "FIXED_DEV_TAG is empty, abort." + exit 1 + fi REPO="$GITHUB_REPOSITORY" RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" + echo "Using release tag: $TAG" - if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then echo "Release $TAG not found, skip notes update." exit 0 fi - ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" + ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")" pick_asset() { local pattern="$1" @@ -350,4 +386,22 @@ jobs: } update_release_notes - gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url + 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 + 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 a6c7b56..52aa2d4 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -86,7 +86,23 @@ jobs: fi gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH" RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')" - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null + RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after create:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi preview-mac-arm64: needs: prepare @@ -108,6 +124,22 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure mac key helpers are executable + shell: bash + run: | + set -euo pipefail + for file in \ + resources/key/macos/universal/xkey_helper \ + resources/key/macos/universal/image_scan_helper \ + resources/key/macos/universal/xkey_helper_macos \ + resources/key/macos/universal/libwx_key.dylib + do + if [ -f "$file" ]; then + chmod +x "$file" + ls -l "$file" + fi + done + - name: Set preview version shell: bash run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version @@ -315,17 +347,22 @@ jobs: run: | set -euo pipefail - TAG="$FIXED_PREVIEW_TAG" + TAG="${FIXED_PREVIEW_TAG:-}" + if [ -z "$TAG" ]; then + echo "FIXED_PREVIEW_TAG is empty, abort." + exit 1 + fi CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}" REPO="$GITHUB_REPOSITORY" RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" + echo "Using release tag: $TAG" - if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then echo "Release $TAG not found (possibly all publish jobs failed), skip notes update." exit 0 fi - ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" + ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")" pick_asset() { local pattern="$1" @@ -392,4 +429,22 @@ jobs: } update_release_notes - gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url + 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 + 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 ed89fb5..44cf1bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,22 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure mac key helpers are executable + shell: bash + run: | + set -euo pipefail + for file in \ + resources/key/macos/universal/xkey_helper \ + resources/key/macos/universal/image_scan_helper \ + resources/key/macos/universal/xkey_helper_macos \ + resources/key/macos/universal/libwx_key.dylib + do + if [ -f "$file" ]; then + chmod +x "$file" + ls -l "$file" + fi + done + - name: Sync version with tag shell: bash run: | diff --git a/README.md b/README.md index 01e7beb..0376588 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,32 @@ # WeFlow -WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告 - ---- +WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。

- WeFlow + + Telegram Channel + + + Stargazers + + + Forks + + + Issues + + + Downloads + + + Star History Rank +

---- -

- -Stargazers - - -Forks - - -Issues - - -Downloads - - -Telegram - + WeFlow 应用预览

- > [!TIP] > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) @@ -47,14 +45,12 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 ## 支持平台与设备 - | 平台 | 设备/架构 | 安装包 | |------|----------|--------| | Windows | Windows10+、x64(amd64) | `.exe` | | macOS | Apple Silicon(M 系列,arm64) | `.dmg` | | Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` | - ## 快速开始 若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。 @@ -93,7 +89,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可 完整接口文档:[点击查看](docs/HTTP-API.md) - ## 面向开发者 如果你想从源码构建或为项目贡献代码,请遵循以下步骤: @@ -108,9 +103,24 @@ npm install # 3. 运行应用(开发模式) npm run dev - ``` +## 构建状态 + +用于开发者排查发布链路,普通用户可忽略: + +

+ + Release Workflow + + + Preview Nightly Workflow + + + Dev Daily Workflow + +

+ ## 致谢 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 @@ -120,18 +130,16 @@ npm run dev 如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡: - -> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6` - +> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6` ## Star History - - - - Star History Chart - + + + + Star History Chart +
diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index c350eb1..40cb2f2 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -1,6 +1,6 @@ import { app, shell } from 'electron' import { join, basename, dirname } from 'path' -import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs' import { execFile, spawn } from 'child_process' import { promisify } from 'util' import crypto from 'crypto' @@ -403,19 +403,71 @@ export class KeyServiceMac { return `'${String(text).replace(/'/g, `'\\''`)}'` } + private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] { + const baseDir = dirname(primaryBinaryPath) + const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib'] + const unique: string[] = [] + for (const name of names) { + const full = join(baseDir, name) + if (!existsSync(full)) continue + if (!unique.includes(full)) unique.push(full) + } + if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) { + unique.unshift(primaryBinaryPath) + } + return unique + } + + private ensureExecutableBitsBestEffort(paths: string[]): void { + for (const p of paths) { + try { + const mode = statSync(p).mode + if ((mode & 0o111) !== 0) continue + chmodSync(p, mode | 0o111) + } catch { + // ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app) + } + } + } + + private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise { + const existing = paths.filter(p => existsSync(p)) + if (existing.length === 0) return + + const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ') + const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000)) + const scriptLines = [ + `set chmodCmd to "/bin/chmod +x ${quotedPaths}"`, + `set timeoutSec to ${timeoutSec}`, + 'with timeout of timeoutSec seconds', + 'do shell script chmodCmd with administrator privileges', + 'end timeout' + ] + + await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { + timeout: timeoutMs + 10_000 + }) + } + private async getDbKeyByHelperElevated( timeoutMs: number, onStatus?: (message: string, level: number) => void ): Promise { const helperPath = this.getHelperPath() + const artifactPaths = this.collectMacKeyArtifactPaths(helperPath) + this.ensureExecutableBitsBestEffort(artifactPaths) const waitMs = Math.max(timeoutMs, 30_000) const timeoutSec = Math.ceil(waitMs / 1000) + 30 const pid = await this.getWeChatPid() + const chmodPart = artifactPaths.length > 0 + ? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}` + : '' + const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}` + const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败 // 通过 try/on error 回传详细错误,避免只看到 "Command failed" const scriptLines = [ - `set helperPath to ${JSON.stringify(helperPath)}`, - `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + `set cmd to ${JSON.stringify(privilegedCmd)}`, `set timeoutSec to ${timeoutSec}`, 'try', 'with timeout of timeoutSec seconds', @@ -751,10 +803,12 @@ export class KeyServiceMac { try { const helperPath = this.getImageScanHelperPath() const ciphertextHex = ciphertext.toString('hex') + const artifactPaths = this.collectMacKeyArtifactPaths(helperPath) + this.ensureExecutableBitsBestEffort(artifactPaths) // 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用) if (!this._needsElevation) { - const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false) + const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths) if (direct.key) return direct.key if (direct.permissionError) { console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式') @@ -765,7 +819,12 @@ export class KeyServiceMac { // 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid) if (this._needsElevation) { - const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true) + try { + await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000) + } catch (e: any) { + console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e) + } + const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths) if (elevated.key) return elevated.key } } catch (e: any) { @@ -868,12 +927,19 @@ export class KeyServiceMac { } private _spawnScanHelper( - helperPath: string, pid: number, ciphertextHex: string, elevated: boolean + helperPath: string, + pid: number, + ciphertextHex: string, + elevated: boolean, + artifactPaths: string[] = [] ): Promise<{ key: string | null; permissionError: boolean }> { return new Promise((resolve, reject) => { let child: ReturnType if (elevated) { - const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}` + const chmodPart = artifactPaths.length > 0 + ? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && ` + : '' + const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}` child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`], { stdio: ['ignore', 'pipe', 'pipe'] }) } else {