diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
deleted file mode 100644
index 2b4c7a9..0000000
--- a/.github/ISSUE_TEMPLATE/bug.yml
+++ /dev/null
@@ -1,140 +0,0 @@
-name: "报告 Bug"
-description: "代码出现了非预期的问题、崩溃或报错"
-title: "[Bug]: "
-labels: ["type: bug", "status: needs info"]
-body:
- - type: markdown
- attributes:
- value: |
- 请提供尽可能详细的信息,帮助我们快速定位和修复问题。
- - type: input
- id: confirm-unique
- attributes:
- label: 确认唯一性
- description: 请输入:我已确认没有相同问题出现
- placeholder: 我已确认没有相同问题出现
- validations:
- required: true
- - type: dropdown
- id: standard-operation
- attributes:
- label: 这是标准操作流程下出现的问题吗?
- description: 确认你是按照正常方式使用,而不是非常规操作
- options:
- - 是,标准操作
- - 否,非标准操作
- validations:
- required: true
- - type: dropdown
- id: code-issue-confirm
- attributes:
- label: 你确认这真的是我们的代码导致的吗?
- description: 请仔细思考,排除网络、系统、第三方服务等外部因素
- options:
- - 是,确认是代码问题
- - 不确定
- - 否,可能是其他原因
- validations:
- required: true
- - type: checkboxes
- id: pre-check
- attributes:
- label: 基础确认
- options:
- - label: 我使用的是最新版本
- required: true
- - label: 我已阅读过相关文档
- required: true
- - type: dropdown
- id: platform
- attributes:
- label: 使用平台
- description: 选择出现问题的平台
- options:
- - Windows
- - macOS
- - Linux
- validations:
- required: true
- - type: dropdown
- id: severity
- attributes:
- label: 问题严重程度
- description: 这个问题对你的使用造成了多大影响?
- options:
- - 严重崩溃或数据丢失(无法使用)
- - 核心功能受影响(在下一个常规发布中必须修复)
- - 边缘场景或轻微问题(等待空闲时修复)
- validations:
- required: true
- - type: textarea
- id: description
- attributes:
- label: 问题描述
- description: 清晰描述你遇到的问题,包括实际发生了什么
- placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏
- validations:
- required: true
- - type: textarea
- id: reproduction
- attributes:
- label: 复现步骤
- description: 提供详细的操作步骤,让我们能够重现这个问题
- placeholder: |
- 1. 打开应用并登录账号
- 2. 进入聊天页面
- 3. 点击发送按钮
- 4. 观察到应用崩溃
- validations:
- required: true
- - type: textarea
- id: expected-behavior
- attributes:
- label: 预期行为
- description: 描述你期望的正确行为应该是什么样的
- placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中
- validations:
- required: true
- - type: textarea
- id: actual-behavior
- attributes:
- label: 实际行为
- description: 描述实际发生的错误行为
- placeholder: 例如:点击后应用直接崩溃,显示白屏
- validations:
- required: true
- - type: textarea
- id: logs
- attributes:
- label: 错误日志或截图
- description: 粘贴控制台错误信息、崩溃日志,或拖入截图
- placeholder: 请粘贴完整的错误堆栈信息
- render: shell
- - type: input
- id: os
- attributes:
- label: 操作系统版本
- description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04
- placeholder: Windows 11 24H2
- validations:
- required: true
- - type: input
- id: app-version
- attributes:
- label: 应用版本
- description: 在关于页面或设置中查看版本号
- placeholder: v1.2.3
- validations:
- required: true
- - type: input
- id: architecture
- attributes:
- label: 系统架构
- description: 例如:x64、arm64
- placeholder: x64
- - type: textarea
- id: additional-context
- attributes:
- label: 补充信息
- description: 其他可能有助于定位问题的信息
- placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
deleted file mode 100644
index 3ba13e0..0000000
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ /dev/null
@@ -1 +0,0 @@
-blank_issues_enabled: false
diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml
deleted file mode 100644
index e3b9db7..0000000
--- a/.github/ISSUE_TEMPLATE/docs.yml
+++ /dev/null
@@ -1,75 +0,0 @@
-name: "文档反馈"
-description: "文档存在错别字、描述不清晰或缺少必要的示例"
-title: "[Docs]: "
-labels: ["type: docs"]
-body:
- - type: markdown
- attributes:
- value: |
- 优秀的文档和代码一样重要。感谢你帮助我们完善文档!
- - type: input
- id: confirm-unique
- attributes:
- label: 确认唯一性
- description: 请输入:我已确认没有相同问题出现
- placeholder: 我已确认没有相同问题出现
- validations:
- required: true
- - type: dropdown
- id: doc-type
- attributes:
- label: 文档类型
- description: 问题出现在哪类文档中?
- options:
- - README 或项目说明
- - 安装部署文档
- - 使用教程
- - API 文档
- - 开发者文档
- - 其他
- validations:
- required: true
- - type: input
- id: doc-link
- attributes:
- label: 文档位置
- description: 提供文档的 URL 或文件路径
- placeholder: 例如:docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx
- validations:
- required: true
- - type: dropdown
- id: issue-type
- attributes:
- label: 问题类型
- description: 文档存在什么问题?
- options:
- - 错别字或语法错误
- - 内容过时或不准确
- - 描述不清晰或有歧义
- - 缺少必要的示例代码
- - 缺少重要的说明或警告
- - 链接失效或错误
- - 其他
- validations:
- required: true
- - type: textarea
- id: issue-desc
- attributes:
- label: 问题描述
- description: 详细说明文档中存在的问题
- placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal"
- validations:
- required: true
- - type: textarea
- id: suggestion
- attributes:
- label: 修改建议
- description: 你认为应该如何修改?
- placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异
- validations:
- required: true
- - type: textarea
- id: additional
- attributes:
- label: 补充说明
- description: 其他需要补充的信息
diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml
deleted file mode 100644
index f37722d..0000000
--- a/.github/ISSUE_TEMPLATE/enhancement.yml
+++ /dev/null
@@ -1,92 +0,0 @@
-name: "功能与体验优化"
-description: "对现有的功能逻辑进行优化,或改进用户体验"
-title: "[Enhancement]: "
-labels: ["type: enhancement"]
-body:
- - type: markdown
- attributes:
- value: |
- 持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
- - type: input
- id: confirm-unique
- attributes:
- label: 确认唯一性
- description: 请输入:我已确认没有相同问题出现
- placeholder: 我已确认没有相同问题出现
- validations:
- required: true
- - type: input
- id: confirm-beneficial
- attributes:
- label: 确认有益性
- description: 请输入:我确认这真的对应用有益
- placeholder: 我确认这真的对应用有益
- validations:
- required: true
- - type: checkboxes
- id: pre-check
- attributes:
- label: 提交前确认
- options:
- - label: 这是对现有功能的改进,而不是全新功能
- required: true
- - type: dropdown
- id: category
- attributes:
- label: 优化类别
- description: 这个优化主要属于哪个方面?
- options:
- - 性能优化(速度、内存、资源占用)
- - 交互体验(操作流程、界面布局)
- - 视觉设计(样式、动画、美观度)
- - 易用性(降低使用门槛、减少操作步骤)
- - 稳定性(减少崩溃、提高可靠性)
- - 其他
- validations:
- required: true
- - type: textarea
- id: target
- attributes:
- label: 目标功能或模块
- description: 你希望优化的具体功能或页面是哪个?
- placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能
- validations:
- required: true
- - type: textarea
- id: current-behavior
- attributes:
- label: 当前表现
- description: 描述当前功能的不足之处或存在的问题
- placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒
- validations:
- required: true
- - type: textarea
- id: improvement
- attributes:
- label: 优化建议
- description: 详细说明你的优化方案和预期效果
- placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内
- validations:
- required: true
- - type: textarea
- id: benefits
- attributes:
- label: 优化收益
- description: 这个优化会带来什么具体好处?
- placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步
- validations:
- required: true
- - type: textarea
- id: impact
- attributes:
- label: 影响范围
- description: 这个优化会影响哪些用户或场景?
- placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景
- - type: checkboxes
- id: contribution
- attributes:
- label: 参与贡献
- options:
- - label: 我愿意提交 Pull Request 来实现这个优化
- validations:
- required: true
diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml
deleted file mode 100644
index 216f223..0000000
--- a/.github/ISSUE_TEMPLATE/feature.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-name: "全新功能请求"
-description: "提议一个目前项目中完全没有的新特性"
-title: "[Feature]: "
-labels: ["type: feature"]
-body:
- - type: markdown
- attributes:
- value: |
- 感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。
- - type: input
- id: confirm-unique
- attributes:
- label: 确认唯一性
- description: 请输入:我已确认没有相同问题出现
- placeholder: 我已确认没有相同问题出现
- validations:
- required: true
- - type: input
- id: confirm-useful
- attributes:
- label: 确认实用性
- description: 请输入:我认为这会对大部分人都有用
- placeholder: 我认为这会对大部分人都有用
- validations:
- required: true
- - type: checkboxes
- id: pre-check
- attributes:
- label: 提交前确认
- options:
- - label: 这是一个全新的功能,而不是对现有功能的改进
- required: true
- - type: dropdown
- id: priority
- attributes:
- label: 功能优先级
- description: 你认为这个功能有多重要?
- options:
- - 高优先级(核心功能缺失,严重影响使用体验)
- - 中优先级(有助于提升使用体验)
- - 低优先级(锦上添花的功能)
- validations:
- required: true
- - type: textarea
- id: problem
- attributes:
- label: 问题或痛点
- description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力?
- placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时
- validations:
- required: true
- - type: textarea
- id: solution
- attributes:
- label: 期望的解决方案
- description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等
- placeholder: 例如:在聊天窗口右键菜单添加"导出记录",点击后弹窗可选时间范围、导出格式(TXT/JSON)、筛选用户,最后保存到本地
- validations:
- required: true
- - type: textarea
- id: use-case
- attributes:
- label: 使用场景
- description: 【什么时候用】你会在哪些具体情况下使用这个功能?
- placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容
- validations:
- required: true
- - type: textarea
- id: alternatives
- attributes:
- label: 替代方案
- description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式?
- placeholder: 例如:目前只能手动截图或逐条复制粘贴
- - type: textarea
- id: reference
- attributes:
- label: 参考示例
- description: 其他应用中是否有类似功能可以参考?
- placeholder: 例如:微信的聊天记录导出功能、Telegram 的导出数据功能
- - type: checkboxes
- id: contribution
- attributes:
- label: 参与贡献
- options:
- - label: 我愿意提交 Pull Request 来实现这个功能
diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml
deleted file mode 100644
index 43ca2e1..0000000
--- a/.github/ISSUE_TEMPLATE/question.yml
+++ /dev/null
@@ -1,77 +0,0 @@
-name: "使用答疑"
-description: "关于如何配置、如何使用项目的求助"
-title: "[Question]: "
-labels: ["type: question"]
-body:
- - type: markdown
- attributes:
- value: |
- 在提问之前,请确保你已经仔细阅读过我们的官方文档。
- - type: input
- id: confirm-unique
- attributes:
- label: 确认唯一性
- description: 请输入:我已确认没有相同问题出现
- placeholder: 我已确认没有相同问题出现
- validations:
- required: true
- - type: checkboxes
- id: pre-check
- attributes:
- label: 提交前确认
- options:
- - label: 我已阅读过相关文档
- required: true
- - type: dropdown
- id: question-type
- attributes:
- label: 问题类型
- description: 你的问题属于哪个方面?
- options:
- - 安装部署问题
- - 配置相关问题
- - 功能使用问题
- - API 调用问题
- - 错误排查问题
- - 其他
- validations:
- required: true
- - type: textarea
- id: question
- attributes:
- label: 问题描述
- description: 清晰描述你遇到的问题或疑问
- placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应
- validations:
- required: true
- - type: textarea
- id: attempts
- attributes:
- label: 已尝试的方法
- description: 你已经尝试过哪些解决方法?
- placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在
- validations:
- required: true
- - type: textarea
- id: environment
- attributes:
- label: 运行环境
- description: 提供你的系统环境信息
- placeholder: |
- 操作系统:Windows 11
- 应用版本:v1.2.3
- 系统架构:x64
- validations:
- required: true
- - type: textarea
- id: code-snippet
- attributes:
- label: 相关配置或代码
- description: 如果涉及配置或代码问题,请粘贴相关内容
- placeholder: 粘贴你的配置文件或代码片段
- render: javascript
- - type: textarea
- id: screenshots
- attributes:
- label: 截图或日志
- description: 如有必要,请提供截图或错误日志
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index cefd3f9..0000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-version: 2
-updates:
- - package-ecosystem: "npm"
- directory: "/"
- schedule:
- interval: "daily"
- target-branch: "dev"
\ No newline at end of file
diff --git a/.github/scripts/release-utils.sh b/.github/scripts/release-utils.sh
deleted file mode 100644
index 390b56a..0000000
--- a/.github/scripts/release-utils.sh
+++ /dev/null
@@ -1,237 +0,0 @@
-#!/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
- local release_api_url
- 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
-
- release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)"
- if [[ "$release_id" =~ ^[0-9]+$ ]]; then
- echo "$release_id"
- return 0
- fi
-
- release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)"
- if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then
- echo "${BASH_REMATCH[1]}"
- 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 release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true
- 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 release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true
- 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 || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)"
- prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 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 release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true
- gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
- return 1
-}
-
-print_release_state() {
- local repo="$1"
- local tag="$2"
-
- gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \
- || gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}'
-}
-
-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/anti-spam.yml b/.github/workflows/anti-spam.yml
deleted file mode 100644
index 5fdd21c..0000000
--- a/.github/workflows/anti-spam.yml
+++ /dev/null
@@ -1,134 +0,0 @@
-name: Anti-Spam
-
-on:
- issues:
- types: [opened, edited]
-
-permissions:
- issues: write
-
-jobs:
- check-spam:
- runs-on: ubuntu-latest
- steps:
- - name: Check for spam
- uses: actions/github-script@v7
- with:
- script: |
- const issue = context.payload.issue;
- const title = (issue.title || '').toLowerCase();
- const body = (issue.body || '').toLowerCase();
- const text = title + ' ' + body;
-
- // 博彩/赌球类
- const gamblingPatterns = [
- /世界杯.*买球/, /买球.*世界杯/,
- /世界杯.*下注/, /世界杯.*竞猜/,
- /世界杯.*投注/, /世界杯.*押注/,
- /世界杯.*彩票/, /世界杯.*平台/,
- /世界杯.*app/, /世界杯.*软件/,
- /世界杯.*网站/, /世界杯.*网址/,
- /足球.*买球/, /买球.*足球/,
- /足球.*投注/, /足球.*押注/,
- /足球.*竞猜/, /足球.*平台/,
- /篮球.*买球/, /篮球.*投注/,
- /体育.*投注/, /体育.*竞猜/,
- /体育.*买球/, /体育.*押注/,
- /赌球/, /赌博.*网站/, /赌博.*平台/,
- /博彩/, /博彩.*网站/, /博彩.*平台/,
- /正规.*买球/, /官方.*买球/,
- /买球.*网站/, /买球.*app/,
- /买球.*软件/, /买球.*网址/,
- /买球.*平台/, /买球.*技巧/,
- /投注.*网站/, /投注.*平台/,
- /押注.*网站/, /押注.*平台/,
- /竞猜.*网站/, /竞猜.*平台/,
- /彩票.*网站/, /彩票.*平台/,
- /欧洲杯.*买球/, /欧冠.*买球/,
- /nba.*买球/, /nba.*投注/,
- ];
-
- // 色情/交友类
- const adultPatterns = [
- /约炮/, /一夜情/, /外围/,
- /包养/, /援交/, /陪聊/,
- /成人.*网站/, /成人.*视频/,
- /av.*网站/, /黄色.*网站/,
- ];
-
- // 贷款/金融诈骗类
- const financePatterns = [
- /秒到账.*贷款/, /无抵押.*贷款/,
- /征信.*贷款/, /黑户.*贷款/,
- /快速.*放款/, /私人.*放贷/,
- /刷单/, /兼职.*日入/, /兼职.*月入/,
- /网赚/, /躺赚/, /被动收入.*平台/,
- /虚拟货币.*投资/, /usdt.*投资/,
- /炒币.*平台/, /数字货币.*平台/,
- ];
-
- // 垃圾推广类
- const spamPromoPatterns = [
- /代刷/, /粉丝.*购买/, /涨粉/,
- /seo.*优化/, /快速排名/,
- /微商/, /代理.*招募/,
- ];
-
- // 账号特征检测(新账号 + 无 contribution)
- const allPatterns = [
- ...gamblingPatterns,
- ...adultPatterns,
- ...financePatterns,
- ...spamPromoPatterns,
- ];
-
- const isSpam = allPatterns.some(pattern => pattern.test(text));
-
- // 额外检测:标题超短且含可疑关键词(常见于批量刷单)
- const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
-
- if (isSpam || suspiciousShort) {
- // 确保 spam label 存在
- try {
- await github.rest.issues.createLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: 'spam',
- color: 'e4e669',
- description: 'Spam issue'
- });
- } catch (e) {
- // label 已存在,忽略
- }
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
- });
-
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- labels: ['spam']
- });
-
- await github.rest.issues.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- state: 'closed',
- state_reason: 'not_planned'
- });
-
- await github.rest.issues.lock({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- lock_reason: 'spam'
- });
-
- console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
- }
diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml
deleted file mode 100644
index 67243ca..0000000
--- a/.github/workflows/dev-daily-fixed.yml
+++ /dev/null
@@ -1,389 +0,0 @@
-name: Dev Daily
-
-on:
- schedule:
- # GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
- - cron: "0 16 * * *"
- workflow_dispatch:
-
-concurrency:
- group: dev-nightly-fixed-release
- cancel-in-progress: true
-
-permissions:
- contents: write
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
- FIXED_DEV_TAG: nightly-dev
- TARGET_BRANCH: dev
- ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
-
-jobs:
- prepare:
- runs-on: ubuntu-latest
- outputs:
- dev_version: ${{ steps.meta.outputs.dev_version }}
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
-
- - name: Generate daily dev version
- id: meta
- shell: bash
- run: |
- set -euo pipefail
- YEAR_2="$(TZ=Asia/Shanghai date +%y)"
- MONTH="$(TZ=Asia/Shanghai date +%-m)"
- DAY="$(TZ=Asia/Shanghai date +%-d)"
- DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
- echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
- echo "Dev version: $DEV_VERSION"
-
- - name: Recreate fixed prerelease
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
- run: |
- set -euo pipefail
- source .github/scripts/release-utils.sh
- recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
-
- dev-mac-arm64:
- needs: prepare
- runs-on: macos-14
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - 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
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package macOS arm64 dev artifacts
- shell: bash
- run: |
- set -euo pipefail
- 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"
- if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then
- echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
- npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
- fi
-
- - 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")
- done < <(find release -maxdepth 1 -type f | sort)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No release files found in ./release"
- exit 1
- fi
- upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
-
- dev-linux:
- needs: prepare
- runs-on: ubuntu-latest
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - name: Install Dependencies
- run: npm install
-
- - name: Set dev version
- shell: bash
- run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package Linux dev artifacts
- run: |
- npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
-
- - name: Upload Linux 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")
- done < <(find release -maxdepth 1 -type f | sort)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No release files found in ./release"
- exit 1
- fi
- upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
-
- dev-win-x64:
- needs: prepare
- runs-on: windows-latest
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - name: Install Dependencies
- run: npm install
-
- - name: Set dev version
- shell: bash
- run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package Windows x64 dev artifacts
- run: |
- npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
-
- - name: Upload Windows x64 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")
- done < <(find release -maxdepth 1 -type f | sort)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No release files found in ./release"
- exit 1
- fi
- upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
-
- dev-win-arm64:
- needs: prepare
- runs-on: windows-latest
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - name: Install Dependencies
- run: npm install
-
- - name: Set dev version
- shell: bash
- run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package Windows arm64 dev artifacts
- run: |
- npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
-
- - name: Upload Windows 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")
- done < <(find release -maxdepth 1 -type f | sort)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No release files found in ./release"
- exit 1
- fi
- upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
-
- update-dev-release-notes:
- needs:
- - prepare
- - dev-mac-arm64
- - dev-linux
- - dev-win-x64
- - dev-win-arm64
- if: always() && needs.prepare.result == 'success'
- runs-on: ubuntu-latest
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 1
-
- - name: Update fixed dev release notes
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
- run: |
- set -euo pipefail
-
- 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 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 api "repos/$REPO/releases/tags/$TAG")"
-
- pick_asset() {
- local pattern="$1"
- echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
- }
-
- WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
- WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
- MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
- if [ -z "$MAC_ASSET" ]; then
- MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")"
- fi
- LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
- LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
-
- build_link() {
- local name="$1"
- if [ -n "$name" ]; then
- echo "https://github.com/$REPO/releases/download/$TAG/$name"
- fi
- }
-
- WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
- WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
- MAC_URL="$(build_link "$MAC_ASSET")"
- LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
- LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
-
- cat > dev_release_notes.md </dev/null 2>&1; then
- return 0
- fi
- if [ "$i" -lt "$attempts" ]; then
- echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
- sleep "$delay_seconds"
- fi
- done
- return 1
- }
-
- update_release_notes
- 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
- print_release_state "$REPO" "$TAG"
diff --git a/.github/workflows/issue-auto-assign.yml b/.github/workflows/issue-auto-assign.yml
deleted file mode 100644
index cc76345..0000000
--- a/.github/workflows/issue-auto-assign.yml
+++ /dev/null
@@ -1,84 +0,0 @@
-name: Issue Auto Assign
-
-on:
- issues:
- types: [opened, edited, reopened]
-
-permissions:
- issues: write
-
-jobs:
- assign-by-platform:
- runs-on: ubuntu-latest
- steps:
- - name: Assign issue by selected platform
- uses: actions/github-script@v7
- env:
- ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }}
- ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }}
- ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }}
- with:
- script: |
- const issue = context.payload.issue;
- if (!issue) {
- core.info("No issue payload.");
- return;
- }
-
- const labels = (issue.labels || []).map((l) => l.name);
- if (!labels.includes("type: bug")) {
- core.info("Skip non-bug issue.");
- return;
- }
-
- const body = issue.body || "";
- const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i);
- if (!match) {
- core.info("No platform field found in issue body.");
- return;
- }
-
- const rawPlatform = match[1].trim().toLowerCase();
- let platformKey = null;
- if (rawPlatform.includes("windows")) platformKey = "windows";
- if (rawPlatform.includes("macos")) platformKey = "macos";
- if (rawPlatform.includes("linux")) platformKey = "linux";
-
- if (!platformKey) {
- core.info(`Unrecognized platform value: ${rawPlatform}`);
- return;
- }
-
- const parseAssignees = (value) =>
- (value || "")
- .split(",")
- .map((v) => v.trim())
- .filter(Boolean);
-
- const assigneeMap = {
- windows: parseAssignees(process.env.ASSIGNEE_WINDOWS),
- macos: parseAssignees(process.env.ASSIGNEE_MACOS),
- linux: parseAssignees(process.env.ASSIGNEE_LINUX),
- };
-
- const candidates = assigneeMap[platformKey] || [];
- if (candidates.length === 0) {
- core.info(`No assignee configured for platform: ${platformKey}`);
- return;
- }
-
- const existing = new Set((issue.assignees || []).map((a) => a.login));
- const toAdd = candidates.filter((u) => !existing.has(u));
- if (toAdd.length === 0) {
- core.info("All configured assignees already assigned.");
- return;
- }
-
- await github.rest.issues.addAssignees({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- assignees: toAdd,
- });
-
- core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`);
diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml
deleted file mode 100644
index 13bc270..0000000
--- a/.github/workflows/preview-nightly-main.yml
+++ /dev/null
@@ -1,432 +0,0 @@
-name: Preview Nightly
-
-on:
- schedule:
- # GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
- - cron: "0 16 * * *"
- workflow_dispatch:
-
-concurrency:
- group: preview-nightly-fixed-release
- cancel-in-progress: true
-
-permissions:
- contents: write
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
- FIXED_PREVIEW_TAG: nightly-preview
- TARGET_BRANCH: main
- ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
-
-jobs:
- prepare:
- runs-on: ubuntu-latest
- outputs:
- should_build: ${{ steps.meta.outputs.should_build }}
- preview_version: ${{ steps.meta.outputs.preview_version }}
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
-
- - name: Decide whether to build and generate preview version
- id: meta
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
- run: |
- set -euo pipefail
-
- git fetch origin main --depth=1
- COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
-
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- SHOULD_BUILD=true
- elif [ "$COMMITS_24H" -gt 0 ]; then
- SHOULD_BUILD=true
- else
- SHOULD_BUILD=false
- fi
-
- YEAR_2="$(TZ=Asia/Shanghai date +%y)"
- YEARLY_RUN_COUNT=1
- LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
- if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
- LAST_YEAR="${BASH_REMATCH[1]}"
- LAST_COUNT="${BASH_REMATCH[2]}"
- if [ "$LAST_YEAR" = "$YEAR_2" ]; then
- YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
- fi
- fi
-
- PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
-
- echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
- echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
- echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
-
- - name: Recreate fixed preview prerelease
- if: steps.meta.outputs.should_build == 'true'
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
- run: |
- set -euo pipefail
- source .github/scripts/release-utils.sh
- recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
-
- preview-mac-arm64:
- needs: prepare
- if: needs.prepare.outputs.should_build == 'true'
- runs-on: macos-14
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - 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
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package macOS arm64 preview artifacts
- env:
- CSC_IDENTITY_AUTO_DISCOVERY: "false"
- shell: bash
- run: |
- set -euo pipefail
- 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"
- if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then
- echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
- npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
- fi
-
- - 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")
- done < <(find release -maxdepth 1 -type f | sort)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No release files found in ./release"
- exit 1
- fi
- upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
-
- preview-linux:
- needs: prepare
- if: needs.prepare.outputs.should_build == 'true'
- runs-on: ubuntu-latest
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - name: Install Dependencies
- run: npm install
-
- - name: Set preview version
- shell: bash
- run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package Linux preview artifacts
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
- run: |
- npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
-
- - name: Upload Linux 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")
- done < <(find release -maxdepth 1 -type f | sort)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No release files found in ./release"
- exit 1
- fi
- upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
-
- preview-win-x64:
- needs: prepare
- if: needs.prepare.outputs.should_build == 'true'
- runs-on: windows-latest
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - name: Install Dependencies
- run: npm install
-
- - name: Set preview version
- shell: bash
- run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package Windows x64 preview artifacts
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
- run: |
- npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
-
- - name: Upload Windows x64 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")
- done < <(find release -maxdepth 1 -type f | sort)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No release files found in ./release"
- exit 1
- fi
- upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
-
- preview-win-arm64:
- needs: prepare
- if: needs.prepare.outputs.should_build == 'true'
- runs-on: windows-latest
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - name: Install Dependencies
- run: npm install
-
- - name: Set preview version
- shell: bash
- run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package Windows arm64 preview artifacts
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
- run: |
- npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
-
- - name: Upload Windows 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")
- done < <(find release -maxdepth 1 -type f | sort)
- if [ "${#assets[@]}" -eq 0 ]; then
- echo "No release files found in ./release"
- exit 1
- fi
- upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
-
- update-preview-release-notes:
- needs:
- - prepare
- - preview-mac-arm64
- - preview-linux
- - preview-win-x64
- - preview-win-arm64
- if: needs.prepare.outputs.should_build == 'true' && always()
- runs-on: ubuntu-latest
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- ref: ${{ env.TARGET_BRANCH }}
- fetch-depth: 1
-
- - name: Update preview release notes
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
- run: |
- set -euo pipefail
-
- 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 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 api "repos/$REPO/releases/tags/$TAG")"
-
- pick_asset() {
- local pattern="$1"
- echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
- }
-
- WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")"
- if [ -z "$WINDOWS_ASSET" ]; then
- WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
- fi
- WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
- MAC_ASSET="$(pick_asset "[.]dmg$")"
- if [ -z "$MAC_ASSET" ]; then
- MAC_ASSET="$(pick_asset "[.]zip$")"
- fi
- LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
- LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
-
- build_link() {
- local name="$1"
- if [ -n "$name" ]; then
- echo "https://github.com/$REPO/releases/download/$TAG/$name"
- fi
- }
-
- WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
- WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
- MAC_URL="$(build_link "$MAC_ASSET")"
- LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
- LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
-
- cat > preview_release_notes.md < 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
- EOF
-
- update_release_notes() {
- local attempts=5
- local delay_seconds=2
- local i
- for ((i=1; i<=attempts; i++)); do
- if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
- return 0
- fi
- if [ "$i" -lt "$attempts" ]; then
- echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
- sleep "$delay_seconds"
- fi
- done
- return 1
- }
-
- update_release_notes
- 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
- print_release_state "$REPO" "$TAG"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 6627afa..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,391 +0,0 @@
-name: Build and Release
-
-on:
- push:
- tags:
- - "v*"
-
-permissions:
- contents: write
-
-env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
- ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
-
-jobs:
- release-mac-arm64:
- runs-on: macos-14
-
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - name: Install Dependencies
- run: npm install --ignore-scripts
-
- - name: Ensure mac key helpers are executable
- shell: bash
- run: |
- set -euo pipefail
- for file in \
- resources/key/macos/universal/xkey_helper \
- resources/key/macos/universal/image_scan_helper \
- resources/key/macos/universal/xkey_helper_macos \
- resources/key/macos/universal/libwx_key.dylib
- do
- if [ -f "$file" ]; then
- chmod +x "$file"
- ls -l "$file"
- fi
- done
-
- - name: Sync version with tag
- shell: bash
- run: |
- VERSION=${GITHUB_REF_NAME#v}
- echo "Syncing package.json version to $VERSION"
- node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- CSC_IDENTITY_AUTO_DISCOVERY: "false"
- shell: bash
- run: |
- set -euo pipefail
- 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"
- if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.npmRebuild=false' '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then
- echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
- npx electron-builder --mac zip --arm64 --publish always '--config.npmRebuild=false' '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
- fi
-
- - 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
- 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
- retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
- done
-
- release-linux:
- runs-on: ubuntu-latest
-
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: "npm"
- - name: Install Dependencies
- run: npm install
-
- - name: Ensure linux key helper is executable
- shell: bash
- run: |
- [ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found"
-
- - name: Sync version with tag
- shell: bash
- run: |
- VERSION=${GITHUB_REF_NAME#v}
- echo "Syncing package.json version to $VERSION"
- node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package and Publish Linux
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
-
- - 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
- 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
- retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
- fi
-
- release:
- runs-on: windows-latest
-
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: 'npm'
- - name: Install Dependencies
- run: npm install
-
- - name: Sync version with tag
- shell: bash
- run: |
- VERSION=${GITHUB_REF_NAME#v}
- echo "Syncing package.json version to $VERSION"
- node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package and Publish
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
-
- - 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
- 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
- retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
- fi
-
- release-windows-arm64:
- runs-on: windows-latest
-
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - name: Install Node.js
- uses: actions/setup-node@v5
- with:
- node-version: 24
- cache: 'npm'
- - name: Install Dependencies
- run: npm install
-
- - name: Sync version with tag
- shell: bash
- run: |
- VERSION=${GITHUB_REF_NAME#v}
- echo "Syncing package.json version to $VERSION"
- node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
-
- - name: Build Frontend & Type Check
- shell: bash
- run: |
- npx tsc
- npx vite build
-
- - name: Package and Publish Windows arm64
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
-
- - 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
- 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
- retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
- fi
-
- update-release-notes:
- runs-on: ubuntu-latest
- needs:
- - release-mac-arm64
- - release-linux
- - release
- - release-windows-arm64
-
- steps:
- - name: Check out git repository
- uses: actions/checkout@v5
- with:
- fetch-depth: 1
-
- - name: Generate release notes with platform download links
- 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"
- RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
- wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
-
- capture_cmd_with_retry ASSETS_JSON 8 3 gh release view "$TAG" --repo "$REPO" --json assets
-
- pick_asset() {
- local pattern="$1"
- echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
- }
-
- WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("x64.*\\.exe$"))][0] // ""')"
- if [ -z "$WINDOWS_ASSET" ]; then
- WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
- fi
- WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
- MAC_ASSET="$(pick_asset "\\.dmg$")"
- if [ -z "$MAC_ASSET" ]; then
- MAC_ASSET="$(pick_asset "arm64\\.zip$")"
- fi
- LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
- LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
-
- build_link() {
- local name="$1"
- if [ -n "$name" ]; then
- echo "https://github.com/$REPO/releases/download/$TAG/$name"
- fi
- }
-
- WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
- WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
- MAC_URL="$(build_link "$MAC_ASSET")"
- LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
- LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
-
- cat > release_notes.md < 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
- EOF
-
- retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
-
- deploy-aur:
- runs-on: ubuntu-latest
- needs: [release-linux]
- if: startsWith(github.ref, 'refs/tags/v')
- steps:
- - name: Check AUR credentials
- id: aur-credentials
- shell: bash
- env:
- AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
- run: |
- if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
- echo "::notice::AUR_SSH_PRIVATE_KEY is not configured; skipping AUR publish."
- echo "enabled=false" >> "$GITHUB_OUTPUT"
- else
- echo "enabled=true" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Checkout code
- if: steps.aur-credentials.outputs.enabled == 'true'
- uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - name: Update PKGBUILD version
- if: steps.aur-credentials.outputs.enabled == 'true'
- run: |
- NEW_VER=$(echo "${{ github.ref_name }}" | sed 's/^v//')
- sed -i "s/^pkgver=.*/pkgver=${NEW_VER}/" resources/installer/linux/PKGBUILD
- sed -i "s/^pkgrel=.*/pkgrel=1/" resources/installer/linux/PKGBUILD
-
- - name: Publish AUR package
- if: steps.aur-credentials.outputs.enabled == 'true'
- uses: KSXGitHub/github-actions-deploy-aur@master
- with:
- pkgname: weflow
- pkgbuild: resources/installer/linux/PKGBUILD
- updpkgsums: true
- assets: |
- resources/installer/linux/weflow.desktop
- resources/installer/linux/icon.png
- resources/installer/linux/.gitignore
-
- ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
- commit_username: H3CoF6
- commit_email: h3cof6@gmail.com
- ssh_keyscan_types: ed25519
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 14d720f..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,80 +0,0 @@
-# Logs
-logs
-*.log
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-pnpm-debug.log*
-lerna-debug.log*
-
-# Dependencies
-node_modules
-dist
-dist-electron
-dist-ssr
-*.local
-test/
-
-# Editor directories and files
-.vscode/*
-!.vscode/extensions.json
-.idea
-.DS_Store
-*.suo
-*.ntvs*
-*.njsproj
-*.sln
-*.sw?
-
-# Build output
-out
-release
-
-# Database
-*.db
-*.db-shm
-*.db-wal
-
-# Environment
-.env
-.env.local
-.env.production
-
-# OS
-Thumbs.db
-
-# Electron dev cache
-.electron/
-.cache/
-
-
-
-# 忽略 Visual Studio 临时文件夹
-.vs/
-# 忽略 IntelliSense 缓存文件
-*.ipch
-*.aps
-
-wcdb/
-!resources/wcdb/
-!resources/wcdb/**
-xkey/
-server/
-*info
-chatlab-format.md
-*.bak
-AGENTS.md
-AGENT.md
-.claude/
-CLAUDE.md
-.agents/
-resources/wx_send
-概述.md
-pnpm-lock.yaml
-/pnpm-workspace.yaml
-wechat-research-site
-.codex
-weflow-web-offical
-/Wedecrypt
-/scripts/syncwcdb.py
-/scripts/syncWedecrypt.py
\ No newline at end of file
diff --git a/.gitleaks.toml b/.gitleaks.toml
deleted file mode 100644
index c127870..0000000
--- a/.gitleaks.toml
+++ /dev/null
@@ -1,23 +0,0 @@
-title = "Gitleaks Config"
-
-[extend]
-# 继承默认规则
-useDefault = true
-
-# 排除误报路径
-[[rules]]
-id = "curl-auth-header"
-[rules.allowlist]
-paths = [
- '''docs/HTTP-API\.md'''
-]
-regexes = [
- '''YOUR_TOKEN'''
-]
-
-[[rules]]
-id = "generic-api-key"
-[rules.allowlist]
-paths = [
- '''src/pages/ChatPage\.tsx'''
-]
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index 38f11c6..0000000
--- a/.npmrc
+++ /dev/null
@@ -1 +0,0 @@
-registry=https://registry.npmjs.org
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index c956c6d..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,141 +0,0 @@
-Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
-
-By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
-
-Section 1 – Definitions.
-
-a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
-
-b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
-
-c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
-
-d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
-
-e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
-
-f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
-
-g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
-
-h. Licensor means the individual(s) or entity(ies) granting rights under this Public License.
-
-i. NonCommercial means not intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.
-
-j. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
-
-k. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
-
-l. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
-
-Section 2 – Scope.
-
-a. License grant.
-
-1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
-A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and
-B. produce, reproduce, and Share Adapted Material for NonCommercial purposes only.
-
-2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
-
-3. Term. The term of this Public License is specified in Section 6(a).
-
-4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
-
-5. Downstream recipients.
-A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
-B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter's License You apply.
-C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
-
-6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
-
-b. Other rights.
-
-1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
-
-2. Patent and trademark rights are not licensed under this Public License.
-
-3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used for other than NonCommercial purposes.
-
-Section 3 – License Conditions.
-
-Your exercise of the Licensed Rights is expressly made subject to the following conditions.
-
-a. Attribution.
-
-1. If You Share the Licensed Material (including in modified form), You must:
-A. retain the following if it is supplied by the Licensor with the Licensed Material:
-i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
-ii. a copyright notice;
-iii. a notice that refers to this Public License;
-iv. a notice that refers to the disclaimer of warranties;
-v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
-B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
-C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
-
-2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
-
-3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
-
-4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
-
-b. ShareAlike.
-
-In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
-
-1. The Adapter's License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-NC-SA Compatible License.
-
-2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
-
-3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
-
-Section 4 – Sui Generis Database Rights.
-
-Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
-
-a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only;
-
-b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
-
-c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
-
-For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights and Sui Generis Database Rights apply to Your use of the Licensed Material.
-
-Section 5 – Disclaimer of Warranties and Limitation of Liability.
-
-a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
-
-b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
-
-c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
-
-Section 6 – Term and Termination.
-
-a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
-
-b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
-
-1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
-2. upon express reinstatement by the Licensor.
-
-For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
-
-c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
-
-d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
-
-Section 7 – Other Terms and Conditions.
-
-a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
-
-b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
-
-Section 8 – Interpretation.
-
-a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
-
-b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
-
-c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
-
-d. Nothing in this Public License constitutes or may be interpreted as a limitation upon or waiver of any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
\ No newline at end of file
diff --git a/README.md b/README.md
index 2c2d9ee..237ab79 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
+> 📢 [加入电报群](https://t.me/weflow_cc)以下载和获取最新信息
+
---
**WeFlow** is a fully local tool for viewing, analyzing, and exporting WeChat chat history in real time. It generates unique analysis reports based on your chat history.
@@ -16,7 +18,6 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
-
diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md
deleted file mode 100644
index da739e2..0000000
--- a/docs/HTTP-API.md
+++ /dev/null
@@ -1,804 +0,0 @@
-# WeFlow HTTP API / Push 文档
-
-WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
-
-## 启用方式
-
-在应用设置页启用 `API 服务`。
-
-- 默认监听地址:`127.0.0.1`
-- 默认端口:`5031`
-- 基础地址:`http://127.0.0.1:5031`
-- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
-
-**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
-
-## 鉴权规范
-
-**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
-
-1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
-2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
-3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
-
-## 接口列表
-
-- `GET|POST /health`
-- `GET|POST /api/v1/health`
-- `GET|POST /api/v1/push/messages`
-- `GET|POST /api/v1/messages`
-- `GET|POST /api/v1/sessions`
-- `GET /api/v1/sessions/:id/messages` (ChatLab Pull)
-- `GET|POST /api/v1/contacts`
-- `GET|POST /api/v1/group-members`
-- `GET|POST /api/v1/media/*`
-
----
-
-## 1. 健康检查
-
-**请求**
-
-```http
-GET /health
-```
-
-或
-
-```http
-GET /api/v1/health
-```
-
-**响应**
-
-```json
-{
- "status": "ok"
-}
-```
-
----
-
-## 2. 主动推送
-
-通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。
-
-**请求**
-
-```http
-GET /api/v1/push/messages
-```
-
-### 说明
-
-- 需要先在设置页开启 `HTTP API 服务`
-- 同时需要开启 `主动推送`
-- 响应类型为 `text/event-stream`
-- 事件名包含 `message.new` 和 `message.revoke`
-- 建议接收端按 `event + rawid` 去重
-
-### 事件字段
-
-- `event`
-- `sessionId`
-- `rawid`
-- `avatarUrl`
-- `sourceName`
-- `groupName`(仅群聊)
-- `content`
-- `timestamp`(消息时间,秒级 Unix 时间戳)
-
-### 示例
-
-```bash
-curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
-```
-
-示例事件:
-
-```text
-event: message.new
-data: {"event":"message.new","sessionId":"xxx@chatroom","sessionType":"group","rawid":"1234567890123456789","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123}
-```
-
-撤回事件示例:
-
-```text
-event: message.revoke
-data: {"event":"message.revoke","sessionId":"wxid_xxx","sessionType":"other","rawid":"1234567890123456789","avatarUrl":"https://example.com/avatar.jpg","sourceName":"张三","content":"对方撤回了一条消息(rawid:1234567890123456789) 内容为“你好”","timestamp":1760000180}
-```
-
----
-
-## 3. 获取消息
-
-> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
-
-读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
-
-**请求**
-
-```http
-GET /api/v1/messages
-```
-
-### 参数
-
-| 参数 | 类型 | 必填 | 说明 |
-| --------- | ------ | ---- | ----------------------------------------------------- |
-| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
-| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
-| `offset` | number | 否 | 分页偏移,默认 `0` |
-| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
-| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
-| `keyword` | string | 否 | 基于消息显示文本过滤 |
-| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
-| `format` | string | 否 | `json` 或 `chatlab` |
-| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
-| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
-| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
-| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
-| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
-
-### 示例
-
-```bash
-curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
-curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1"
-curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131"
-curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
-```
-
-### JSON 响应字段
-
-顶层字段:
-
-- `success`
-- `talker`
-- `count`
-- `hasMore`
-- `media.enabled`
-- `media.exportPath`
-- `media.count`
-- `messages`
-
-单条消息字段:
-
-- `localId`
-- `serverId`
-- `localType`
-- `createTime`
-- `isSend`
-- `senderUsername`
-- `content`
-- `rawContent`
-- `parsedContent`
-- `replyToMessageId`(引用回复目标消息的 `serverId`,仅引用消息返回)
-- `quote`(引用消息快照,包含被引用消息的 ID、发送者、内容和类型)
-- `mediaType`
-- `mediaFileName`
-- `mediaUrl`
-- `mediaLocalPath`
-
-**示例响应**
-
-```json
-{
- "success": true,
- "talker": "xxx@chatroom",
- "count": 3,
- "hasMore": true,
- "media": {
- "enabled": true,
- "exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
- "count": 1
- },
- "messages": [
- {
- "localId": 123,
- "serverId": "6116895530414915131",
- "localType": 1,
- "createTime": 1738713600,
- "isSend": 0,
- "senderUsername": "wxid_member",
- "content": "你好",
- "rawContent": "你好",
- "parsedContent": "你好"
- },
- {
- "localId": 125,
- "serverId": "6116895530414915133",
- "localType": 244813135921,
- "createTime": 1738713700,
- "isSend": 0,
- "senderUsername": "wxid_member",
- "content": "收到",
- "rawContent": "...",
- "parsedContent": "收到",
- "replyToMessageId": "6116895530414915131",
- "quote": {
- "platformMessageId": "6116895530414915131",
- "sender": "wxid_other",
- "accountName": "张三",
- "content": "你好",
- "type": 0
- }
- },
- {
- "localId": 124,
- "localType": 3,
- "createTime": 1738713660,
- "isSend": 0,
- "senderUsername": "wxid_member",
- "content": "[图片]",
- "mediaType": "image",
- "mediaFileName": "abc123.jpg",
- "mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
- "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
- }
- ]
-}
-```
-
-### ChatLab 响应
-
-当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
-
-- `chatlab.version`
-- `chatlab.exportedAt`
-- `chatlab.generator`
-- `meta.name`
-- `meta.platform`
-- `meta.type`
-- `meta.groupId`
-- `meta.groupAvatar`
-- `meta.ownerId`
-- `members[].platformId`
-- `members[].accountName`
-- `members[].groupNickname`
-- `members[].avatar`
-- `messages[].sender`
-- `messages[].accountName`
-- `messages[].groupNickname`
-- `messages[].timestamp`
-- `messages[].type`
-- `messages[].content`
-- `messages[].platformMessageId`
-- `messages[].replyToMessageId`
-- `messages[].mediaPath`
-
-群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
-
----
-
-## 4. 获取会话列表
-
-> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
-
-**请求**
-
-```http
-GET /api/v1/sessions
-```
-
-### 参数
-
-| 参数 | 类型 | 必填 | 说明 |
-| --------- | ------ | ---- | -------------------------------- |
-| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
-| `limit` | number | 否 | 默认 `100` |
-
-### 响应字段
-
-- `success`
-- `count`
-- `sessions[].username`
-- `sessions[].displayName`
-- `sessions[].type`
-- `sessions[].lastTimestamp`
-- `sessions[].unreadCount`
-
-**示例响应**
-
-```json
-{
- "success": true,
- "count": 1,
- "sessions": [
- {
- "username": "xxx@chatroom",
- "displayName": "项目群",
- "type": 2,
- "lastTimestamp": 1738713600,
- "unreadCount": 0
- }
- ]
-}
-```
-
----
-
-## 4.1 获取会话列表(ChatLab 格式)
-
-当 `format=chatlab` 时,返回 ChatLab Pull 协议兼容格式,可直接作为 ChatLab 远程数据源。
-
-**请求**
-
-```http
-GET /api/v1/sessions?format=chatlab
-```
-
-### 参数
-
-| 参数 | 类型 | 必填 | 说明 |
-| --------- | ------ | ---- | -------------------------------- |
-| `format` | string | 是 | 设为 `chatlab` |
-| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
-| `limit` | number | 否 | 默认 `100` |
-
-### 响应
-
-```json
-{
- "sessions": [
- {
- "id": "xxx@chatroom",
- "name": "项目群",
- "platform": "wechat",
- "type": "group",
- "messageCount": 58000,
- "lastMessageAt": 1738713600
- }
- ]
-}
-```
-
-| 字段 | 说明 |
-| --------------- | ----------------------------------- |
-| `id` | 会话 ID(微信 username) |
-| `name` | 会话显示名称 |
-| `platform` | 固定 `wechat` |
-| `type` | `group`(群聊)或 `private`(私聊) |
-| `messageCount` | 消息数量(估算值,可能不精确) |
-| `lastMessageAt` | 最后消息的秒级 Unix 时间戳 |
-
----
-
-## 4.2 拉取会话消息(ChatLab Pull)
-
-返回 ChatLab 标准格式的聊天数据,支持增量拉取和分页。
-
-**请求**
-
-```http
-GET /api/v1/sessions/:id/messages
-```
-
-### 参数
-
-| 参数 | 类型 | 必填 | 说明 |
-| -------- | ------ | ---- | ---------------------------------------- |
-| `:id` | string | 是 | 会话 ID(Path 参数) |
-| `since` | number | 否 | 秒级 Unix 时间戳,仅返回此时间之后的消息 |
-| `end` | number | 否 | 秒级 Unix 时间戳,时间上界 |
-| `limit` | number | 否 | 单次返回上限,默认且最大 `5000` |
-| `offset` | number | 否 | 分页偏移,默认 `0` |
-
-### 响应
-
-返回 ChatLab 标准 JSON 格式,外加 `sync` 分页块:
-
-```json
-{
- "chatlab": {
- "version": "0.0.2",
- "exportedAt": 1738713600,
- "generator": "WeFlow"
- },
- "meta": {
- "name": "项目群",
- "platform": "wechat",
- "type": "group",
- "groupId": "xxx@chatroom",
- "ownerId": "wxid_xxx"
- },
- "members": [
- {
- "platformId": "wxid_a",
- "accountName": "张三",
- "groupNickname": "产品",
- "avatar": "https://example.com/avatar.jpg"
- }
- ],
- "messages": [
- {
- "sender": "wxid_a",
- "accountName": "张三",
- "timestamp": 1738713600,
- "type": 0,
- "content": "你好",
- "platformMessageId": "123456"
- }
- ],
- "sync": {
- "hasMore": true,
- "nextSince": 1738713600,
- "nextOffset": 5000,
- "watermark": 1738714000
- }
-}
-```
-
-### sync 块
-
-| 字段 | 说明 |
-| ------------ | -------------------------------- |
-| `hasMore` | 是否还有更多数据 |
-| `nextSince` | 下次请求的 `since` 值 |
-| `nextOffset` | 下次请求的 `offset` 值 |
-| `watermark` | 本次拉取的时间上界(秒级时间戳) |
-
-**ChatLab 对接方式**:在 ChatLab 设置中添加远程数据源,`baseUrl` 填 `http://127.0.0.1:5031/api/v1`,Token 填 WeFlow 中配置的 API Token。
-
----
-
-## 5. 获取联系人列表
-
-> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
-
-**请求**
-
-```http
-GET /api/v1/contacts
-```
-
-### 参数
-
-| 参数 | 类型 | 必填 | 说明 |
-| --------- | ------ | ---- | ---------------------------------------------------- |
-| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
-| `limit` | number | 否 | 默认 `100` |
-
-### 响应字段
-
-- `success`
-- `count`
-- `contacts[].username`
-- `contacts[].displayName`
-- `contacts[].remark`
-- `contacts[].nickname`
-- `contacts[].alias`
-- `contacts[].avatarUrl`
-- `contacts[].type`
-
-**示例响应**
-
-```json
-{
- "success": true,
- "count": 1,
- "contacts": [
- {
- "username": "wxid_xxx",
- "displayName": "张三",
- "remark": "客户张三",
- "nickname": "张三",
- "alias": "zhangsan",
- "avatarUrl": "https://example.com/avatar.jpg",
- "type": "friend"
- }
- ]
-}
-```
-
----
-
-## 6. 获取群成员列表
-
-> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
-
-返回群成员的 `wxid`、群昵称、备注、微信号等信息。
-
-**请求**
-
-```http
-GET /api/v1/group-members
-```
-
-### 参数
-
-| 参数 | 类型 | 必填 | 说明 |
-| ---------------------- | ------ | ---- | ------------------------------- |
-| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
-| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
-| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
-| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
-
-### 响应字段
-
-- `success`
-- `chatroomId`
-- `count`
-- `fromCache`
-- `updatedAt`
-- `members[].wxid`
-- `members[].displayName`
-- `members[].nickname`
-- `members[].remark`
-- `members[].alias`
-- `members[].groupNickname`
-- `members[].avatarUrl`
-- `members[].isOwner`
-- `members[].isFriend`
-- `members[].messageCount`
-
-**示例请求**
-
-```bash
-curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
-curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1"
-```
-
-**示例响应**
-
-```json
-{
- "success": true,
- "chatroomId": "xxx@chatroom",
- "count": 2,
- "fromCache": false,
- "updatedAt": 1760000000000,
- "members": [
- {
- "wxid": "wxid_member_a",
- "displayName": "客户A",
- "nickname": "阿甲",
- "remark": "客户A",
- "alias": "kehua",
- "groupNickname": "甲方",
- "avatarUrl": "https://example.com/a.jpg",
- "isOwner": true,
- "isFriend": true,
- "messageCount": 128
- },
- {
- "wxid": "wxid_member_b",
- "displayName": "李四",
- "nickname": "李四",
- "remark": "",
- "alias": "",
- "groupNickname": "",
- "avatarUrl": "",
- "isOwner": false,
- "isFriend": false,
- "messageCount": 0
- }
- ]
-}
-```
-
-说明:
-
-- `displayName` 是当前应用内的主展示名。
-- `groupNickname` 是成员在该群里的群昵称。
-- `remark` 是你对该联系人的备注。
-- `alias` 是微信号。
-- 当微信源数据里没有群昵称时,`groupNickname` 会为空。
-
----
-
-## 7. 朋友圈接口
-
-### 7.1 获取朋友圈时间线
-
-```http
-GET /api/v1/sns/timeline
-```
-
-参数:
-
-| 参数 | 类型 | 必填 | 说明 |
-| ----------- | ------ | ---- | ------------------------------------------------------------ |
-| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` |
-| `offset` | number | 否 | 偏移量,默认 0 |
-| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
-| `keyword` | string | 否 | 关键词过滤(正文) |
-| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
-| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
-| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
-| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
-| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` |
-
-示例:
-
-```bash
-curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
-curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
-curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
-curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
-```
-
-媒体字段说明(`media=1`):
-
-- `media[].url/thumb`:你应该优先直接使用的字段。
-- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。
-- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
-- `media[].rawUrl/rawThumb`:原始朋友圈地址
-- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
-- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL)
-- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。
-- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
-- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
-
-### 7.2 获取朋友圈发布者
-
-```http
-GET /api/v1/sns/usernames
-```
-
-### 7.3 获取朋友圈导出统计
-
-```http
-GET /api/v1/sns/export/stats
-```
-
-参数:
-
-| 参数 | 类型 | 必填 | 说明 |
-| ------ | ------ | ---- | ---------------------------- |
-| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
-
-### 7.4 朋友圈媒体代理
-
-```http
-GET /api/v1/sns/media/proxy
-```
-
-参数:
-
-| 参数 | 类型 | 必填 | 说明 |
-| ----- | ------------- | ---- | ------------------------ |
-| `url` | string | 是 | 媒体原始 URL |
-| `key` | string/number | 否 | 解密 key(部分资源需要) |
-
-### 7.5 导出朋友圈
-
-```http
-POST /api/v1/sns/export
-Content-Type: application/json
-```
-
-Body 示例:
-
-```json
-{
- "outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
- "format": "json",
- "usernames": "wxid_a,wxid_b",
- "keyword": "旅行",
- "exportMedia": true,
- "exportImages": true,
- "exportLivePhotos": true,
- "exportVideos": true,
- "start": "20250101",
- "end": "20251231"
-}
-```
-
-`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。
-
-### 7.6 朋友圈防删开关
-
-```http
-GET /api/v1/sns/block-delete/status
-POST /api/v1/sns/block-delete/install
-POST /api/v1/sns/block-delete/uninstall
-```
-
-### 7.7 删除单条朋友圈
-
-```http
-DELETE /api/v1/sns/post/{postId}
-```
-
----
-
-## 8. 访问导出媒体
-
-> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
-
-通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
-
-**请求**
-
-```http
-GET /api/v1/media/{relativePath}
-```
-
-### 示例
-
-```bash
-curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg"
-curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav"
-curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4"
-curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
-```
-
-### 支持的 Content-Type
-
-| 扩展名 | Content-Type |
-| ---------------- | ------------ |
-| `.png` | `image/png` |
-| `.jpg` / `.jpeg` | `image/jpeg` |
-| `.gif` | `image/gif` |
-| `.webp` | `image/webp` |
-| `.wav` | `audio/wav` |
-| `.mp3` | `audio/mpeg` |
-| `.mp4` | `video/mp4` |
-
-常见错误响应:
-
-```json
-{
- "error": "Media not found"
-}
-```
-
----
-
-## 9. 使用示例
-
-### PowerShell
-
-```powershell
-$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
-$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
-
-Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
-```
-
-### cURL
-
-```bash
-# GET 带 Token Header
-curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
-
-# POST 带 JSON Body
-curl -X POST http://127.0.0.1:5031/api/v1/messages \
- -H "Authorization: Bearer YOUR_TOKEN" \
- -H "Content-Type: application/json" \
- -d '{"talker": "xxx@chatroom", "chatlab": true}'
-```
-
-### Python
-
-```python
-import requests
-
-BASE_URL = "http://127.0.0.1:5031"
-headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
-
-# POST 方式获取消息
-messages = requests.post(
- f"{BASE_URL}/api/v1/messages",
- json={"talker": "xxx@chatroom", "limit": 50},
- headers=headers
-).json()
-
-# GET 方式获取群成员
-members = requests.get(
- f"{BASE_URL}/api/v1/group-members",
- params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
- headers=headers
-).json()
-```
-
----
-
-## 10. 注意事项
-
-1. API 仅监听本机 `127.0.0.1`,不对外网开放。
-2. 使用前需要先在 WeFlow 中完成数据库连接。
-3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
-4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
-5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。
diff --git a/docs/MAC-KEY-FAQ.md b/docs/MAC-KEY-FAQ.md
deleted file mode 100644
index fb287de..0000000
--- a/docs/MAC-KEY-FAQ.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# macOS 微信密钥自动获取失败排障指南
-
-如果你在 macOS 系统下,遇到了 WeFlow 自动获取微信数据库密钥失败的问题,这篇指南或许可以帮到你。
-
-### 请立刻停止连续重试
-
-当你看到下面这些报错时,请务必暂停操作,不要再去反复点击获取:
-
-- SCAN_FAILED,通常伴随 No suitable module found 或 Sink pattern not found
-- HOOK_FAILED 或 Native Hook Failed
-- patch_breakpoint_failed
-- thread_get_state_failed
-
-现在的 macOS 系统和微信防护机制非常敏锐。连续的重试动作不仅无法解决问题,反而容易被判定为异常行为,进而触发微信的安全模式或系统级的内存保护。
-
-### 可能的尝试流程
-
-根据大量社区用户的反馈,如果你已经遇到了获取失败的情况,按照下面的步骤顺序操作,通常都能顺利解决问题:
-
-1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
-2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
-3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
-4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
-5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
-6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
-7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
-
-### 常见报错与应对方法
-
-为了方便排查,这里列出了几类最常见的报错及其背后的原因和对策:
-
-**SCAN_FAILED: No suitable module found**
-这意味着微信的内存布局并不标准,或者目标模块没有被命中。你可以先确保微信完整启动并保持在前台。如果还是不行,请直接执行上面提到的“降级、重启电脑、获取、再升级”的完整流程。
-
-**SCAN_FAILED: Sink pattern not found**
-这说明 WeFlow 还没有适配你当前正在使用的微信版本特征。最快的解决办法是直接降级到微信 4.1.7 或 4.1.8.100 版本再试。
-
-**patch_breakpoint_failed 或 thread_get_state_failed**
-这类错误大多是因为调试断点注入或线程状态读取被 macOS 系统的安全机制拦截了。此时继续尝试毫无意义,彻底退出微信并重启电脑再试。
-
-**task_for_pid:5**
-这是进程附加权限被系统拒绝的提示。请确保你使用的是打包好的 WeFlow.app,同时检查系统的签名与调试权限是否已经正确配置。
-
-### 关于推荐版本的补充说明
-
-截至 2026 年 4 月,综合社区的反馈来看,微信 4.1.7 和 4.1.8.100 版本在密钥获取流程中的表现最为稳定,成功率最高。
-
-这并不意味着其他新版本绝对无法获取,只是作为当前的排障参考。未来 WeFlow 也会在后续的更新中逐步适配新版微信的特征,建议大家多留意项目的 Release 动态。
-
-### 最后的几点建议
-
-首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
-
-最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
diff --git a/electron/annualReportWorker.ts b/electron/annualReportWorker.ts
deleted file mode 100644
index 37db57d..0000000
--- a/electron/annualReportWorker.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { parentPort, workerData } from 'worker_threads'
-import { wcdbService } from './services/wcdbService'
-import { annualReportService } from './services/annualReportService'
-
-interface WorkerConfig {
- year: number
- dbPath: string
- decryptKey: string
- myWxid: string
- resourcesPath?: string
- userDataPath?: string
- logEnabled?: boolean
-}
-
-const config = workerData as WorkerConfig
-process.env.WEFLOW_WORKER = '1'
-if (config.resourcesPath) {
- process.env.WCDB_RESOURCES_PATH = config.resourcesPath
-}
-
-wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
-wcdbService.setLogEnabled(config.logEnabled === true)
-
-async function run() {
- const result = await annualReportService.generateReportWithConfig({
- year: config.year,
- dbPath: config.dbPath,
- decryptKey: config.decryptKey,
- wxid: config.myWxid,
- onProgress: (status: string, progress: number) => {
- parentPort?.postMessage({
- type: 'annualReport:progress',
- data: { status, progress }
- })
- }
- })
-
- parentPort?.postMessage({ type: 'annualReport:result', data: result })
-}
-
-run().catch((err) => {
- parentPort?.postMessage({ type: 'annualReport:error', error: String(err) })
-})
diff --git a/electron/assets/wasm/wasm_video_decode.js b/electron/assets/wasm/wasm_video_decode.js
deleted file mode 100644
index 0a2aee2..0000000
--- a/electron/assets/wasm/wasm_video_decode.js
+++ /dev/null
@@ -1,4707 +0,0 @@
-
-
-// The Module object: Our interface to the outside world. We import
-// and export values on it. There are various ways Module can be used:
-// 1. Not defined. We create it here
-// 2. A function parameter, function(Module) { ..generated code.. }
-// 3. pre-run appended it, var Module = {}; ..generated code..
-// 4. External script tag defines var Module.
-// We need to check if Module already exists (e.g. case 3 above).
-// Substitution will be replaced with actual code on later stage of the build,
-// this way Closure Compiler will not mangle it (e.g. case 4. above).
-// Note that if you want to run closure, and also to use Module
-// after the generated code, you will need to define var Module = {};
-// before the code. Then that object will be used in the code, and you
-// can continue to use Module afterwards as well.
-var Module = typeof Module !== 'undefined' ? Module : {};
-
-// --pre-jses are emitted after the Module integration code, so that they can
-// refer to Module (if they choose; they can also define Module)
-// {{PRE_JSES}}
-
-// Sometimes an existing Module object exists with properties
-// meant to overwrite the default module functionality. Here
-// we collect those properties and reapply _after_ we configure
-// the current environment's defaults to avoid having to be so
-// defensive during initialization.
-var moduleOverrides = {};
-var key;
-for (key in Module) {
- if (Module.hasOwnProperty(key)) {
- moduleOverrides[key] = Module[key];
- }
-}
-
-var arguments_ = [];
-var thisProgram = './this.program';
-var quit_ = function(status, toThrow) {
- throw toThrow;
-};
-
-// Determine the runtime environment we are in. You can customize this by
-// setting the ENVIRONMENT setting at compile time (see settings.js).
-
-var ENVIRONMENT_IS_WEB = false;
-var ENVIRONMENT_IS_WORKER = true;
-var ENVIRONMENT_IS_NODE = false;
-var ENVIRONMENT_IS_SHELL = false;
-
-// `/` should be present at the end if `scriptDirectory` is not empty
-var scriptDirectory = '';
-function locateFile(path) {
- if (Module['locateFile']) {
- return Module['locateFile'](path, scriptDirectory);
- }
- return scriptDirectory + path;
-}
-
-// Hooks that are implemented differently in different runtime environments.
-var read_,
- readAsync,
- readBinary,
- setWindowTitle;
-
-// Note that this includes Node.js workers when relevant (pthreads is enabled).
-// Node.js workers are detected as a combination of ENVIRONMENT_IS_WORKER and
-// ENVIRONMENT_IS_NODE.
-if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) {
- if (ENVIRONMENT_IS_WORKER) { // Check worker, not web, since window could be polyfilled
- scriptDirectory = self.location.href;
- } else if (typeof document !== 'undefined' && document.currentScript) { // web
- scriptDirectory = document.currentScript.src;
- }
- // blob urls look like blob:http://site.com/etc/etc and we cannot infer anything from them.
- // otherwise, slice off the final part of the url to find the script directory.
- // if scriptDirectory does not contain a slash, lastIndexOf will return -1,
- // and scriptDirectory will correctly be replaced with an empty string.
- // If scriptDirectory contains a query (starting with ?) or a fragment (starting with #),
- // they are removed because they could contain a slash.
- if (scriptDirectory.indexOf('blob:') !== 0) {
- scriptDirectory = scriptDirectory.substr(0, scriptDirectory.replace(/[?#].*/, "").lastIndexOf('/')+1);
- } else {
- scriptDirectory = '';
- }
-
- // Differentiate the Web Worker from the Node Worker case, as reading must
- // be done differently.
- {
-
-// include: web_or_worker_shell_read.js
-
-
- read_ = function(url) {
- var xhr = new XMLHttpRequest();
- xhr.open('GET', url, false);
- xhr.send(null);
- return xhr.responseText;
- };
-
- if (ENVIRONMENT_IS_WORKER) {
- readBinary = function(url) {
- var xhr = new XMLHttpRequest();
- xhr.open('GET', url, false);
- xhr.responseType = 'arraybuffer';
- xhr.send(null);
- return new Uint8Array(/** @type{!ArrayBuffer} */(xhr.response));
- };
- }
-
- readAsync = function(url, onload, onerror) {
- var xhr = new XMLHttpRequest();
- xhr.open('GET', url, true);
- xhr.responseType = 'arraybuffer';
- xhr.onload = function() {
- if (xhr.status == 200 || (xhr.status == 0 && xhr.response)) { // file URLs can return 0
- onload(xhr.response);
- return;
- }
- onerror();
- };
- xhr.onerror = onerror;
- xhr.send(null);
- };
-
-// end include: web_or_worker_shell_read.js
- }
-
- setWindowTitle = function(title) { document.title = title };
-} else
-{
-}
-
-var out = Module['print'] || console.log.bind(console);
-var err = Module['printErr'] || console.warn.bind(console);
-
-// Merge back in the overrides
-for (key in moduleOverrides) {
- if (moduleOverrides.hasOwnProperty(key)) {
- Module[key] = moduleOverrides[key];
- }
-}
-// Free the object hierarchy contained in the overrides, this lets the GC
-// reclaim data used e.g. in memoryInitializerRequest, which is a large typed array.
-moduleOverrides = null;
-
-// Emit code to handle expected values on the Module object. This applies Module.x
-// to the proper local x. This has two benefits: first, we only emit it if it is
-// expected to arrive, and second, by using a local everywhere else that can be
-// minified.
-
-if (Module['arguments']) arguments_ = Module['arguments'];
-
-if (Module['thisProgram']) thisProgram = Module['thisProgram'];
-
-if (Module['quit']) quit_ = Module['quit'];
-
-// perform assertions in shell.js after we set up out() and err(), as otherwise if an assertion fails it cannot print the message
-
-
-
-
-var STACK_ALIGN = 16;
-var POINTER_SIZE = 4;
-
-function getNativeTypeSize(type) {
- switch (type) {
- case 'i1': case 'i8': return 1;
- case 'i16': return 2;
- case 'i32': return 4;
- case 'i64': return 8;
- case 'float': return 4;
- case 'double': return 8;
- default: {
- if (type[type.length-1] === '*') {
- return POINTER_SIZE;
- } else if (type[0] === 'i') {
- var bits = Number(type.substr(1));
- assert(bits % 8 === 0, 'getNativeTypeSize invalid bits ' + bits + ', type ' + type);
- return bits / 8;
- } else {
- return 0;
- }
- }
- }
-}
-
-function warnOnce(text) {
- if (!warnOnce.shown) warnOnce.shown = {};
- if (!warnOnce.shown[text]) {
- warnOnce.shown[text] = 1;
- err(text);
- }
-}
-
-// include: runtime_functions.js
-
-
-// Wraps a JS function as a wasm function with a given signature.
-function convertJsFunctionToWasm(func, sig) {
-
- // If the type reflection proposal is available, use the new
- // "WebAssembly.Function" constructor.
- // Otherwise, construct a minimal wasm module importing the JS function and
- // re-exporting it.
- if (typeof WebAssembly.Function === "function") {
- var typeNames = {
- 'i': 'i32',
- 'j': 'i64',
- 'f': 'f32',
- 'd': 'f64'
- };
- var type = {
- parameters: [],
- results: sig[0] == 'v' ? [] : [typeNames[sig[0]]]
- };
- for (var i = 1; i < sig.length; ++i) {
- type.parameters.push(typeNames[sig[i]]);
- }
- return new WebAssembly.Function(type, func);
- }
-
- // The module is static, with the exception of the type section, which is
- // generated based on the signature passed in.
- var typeSection = [
- 0x01, // id: section,
- 0x00, // length: 0 (placeholder)
- 0x01, // count: 1
- 0x60, // form: func
- ];
- var sigRet = sig.slice(0, 1);
- var sigParam = sig.slice(1);
- var typeCodes = {
- 'i': 0x7f, // i32
- 'j': 0x7e, // i64
- 'f': 0x7d, // f32
- 'd': 0x7c, // f64
- };
-
- // Parameters, length + signatures
- typeSection.push(sigParam.length);
- for (var i = 0; i < sigParam.length; ++i) {
- typeSection.push(typeCodes[sigParam[i]]);
- }
-
- // Return values, length + signatures
- // With no multi-return in MVP, either 0 (void) or 1 (anything else)
- if (sigRet == 'v') {
- typeSection.push(0x00);
- } else {
- typeSection = typeSection.concat([0x01, typeCodes[sigRet]]);
- }
-
- // Write the overall length of the type section back into the section header
- // (excepting the 2 bytes for the section id and length)
- typeSection[1] = typeSection.length - 2;
-
- // Rest of the module is static
- var bytes = new Uint8Array([
- 0x00, 0x61, 0x73, 0x6d, // magic ("\0asm")
- 0x01, 0x00, 0x00, 0x00, // version: 1
- ].concat(typeSection, [
- 0x02, 0x07, // import section
- // (import "e" "f" (func 0 (type 0)))
- 0x01, 0x01, 0x65, 0x01, 0x66, 0x00, 0x00,
- 0x07, 0x05, // export section
- // (export "f" (func 0 (type 0)))
- 0x01, 0x01, 0x66, 0x00, 0x00,
- ]));
-
- // We can compile this wasm module synchronously because it is very small.
- // This accepts an import (at "e.f"), that it reroutes to an export (at "f")
- var module = new WebAssembly.Module(bytes);
- var instance = new WebAssembly.Instance(module, {
- 'e': {
- 'f': func
- }
- });
- var wrappedFunc = instance.exports['f'];
- return wrappedFunc;
-}
-
-var freeTableIndexes = [];
-
-// Weak map of functions in the table to their indexes, created on first use.
-var functionsInTableMap;
-
-function getEmptyTableSlot() {
- // Reuse a free index if there is one, otherwise grow.
- if (freeTableIndexes.length) {
- return freeTableIndexes.pop();
- }
- // Grow the table
- try {
- wasmTable.grow(1);
- } catch (err) {
- if (!(err instanceof RangeError)) {
- throw err;
- }
- throw 'Unable to grow wasm table. Set ALLOW_TABLE_GROWTH.';
- }
- return wasmTable.length - 1;
-}
-
-function updateTableMap(offset, count) {
- for (var i = offset; i < offset + count; i++) {
- var item = getWasmTableEntry(i);
- // Ignore null values.
- if (item) {
- functionsInTableMap.set(item, i);
- }
- }
-}
-
-// Add a function to the table.
-// 'sig' parameter is required if the function being added is a JS function.
-function addFunction(func, sig) {
-
- // Check if the function is already in the table, to ensure each function
- // gets a unique index. First, create the map if this is the first use.
- if (!functionsInTableMap) {
- functionsInTableMap = new WeakMap();
- updateTableMap(0, wasmTable.length);
- }
- if (functionsInTableMap.has(func)) {
- return functionsInTableMap.get(func);
- }
-
- // It's not in the table, add it now.
-
- var ret = getEmptyTableSlot();
-
- // Set the new value.
- try {
- // Attempting to call this with JS function will cause of table.set() to fail
- setWasmTableEntry(ret, func);
- } catch (err) {
- if (!(err instanceof TypeError)) {
- throw err;
- }
- var wrapped = convertJsFunctionToWasm(func, sig);
- setWasmTableEntry(ret, wrapped);
- }
-
- functionsInTableMap.set(func, ret);
-
- return ret;
-}
-
-function removeFunction(index) {
- functionsInTableMap.delete(getWasmTableEntry(index));
- freeTableIndexes.push(index);
-}
-
-// end include: runtime_functions.js
-// include: runtime_debug.js
-
-
-// end include: runtime_debug.js
-var tempRet0 = 0;
-
-var setTempRet0 = function(value) {
- tempRet0 = value;
-};
-
-var getTempRet0 = function() {
- return tempRet0;
-};
-
-
-
-// === Preamble library stuff ===
-
-// Documentation for the public APIs defined in this file must be updated in:
-// site/source/docs/api_reference/preamble.js.rst
-// A prebuilt local version of the documentation is available at:
-// site/build/text/docs/api_reference/preamble.js.txt
-// You can also build docs locally as HTML or other formats in site/
-// An online HTML version (which may be of a different version of Emscripten)
-// is up at http://kripken.github.io/emscripten-site/docs/api_reference/preamble.js.html
-
-var wasmBinary;
-if (Module['wasmBinary']) wasmBinary = Module['wasmBinary'];
-var noExitRuntime = Module['noExitRuntime'] || true;
-
-if (typeof WebAssembly !== 'object') {
- abort('no native wasm support detected');
-}
-
-// include: runtime_safe_heap.js
-
-
-// In MINIMAL_RUNTIME, setValue() and getValue() are only available when building with safe heap enabled, for heap safety checking.
-// In traditional runtime, setValue() and getValue() are always available (although their use is highly discouraged due to perf penalties)
-
-/** @param {number} ptr
- @param {number} value
- @param {string} type
- @param {number|boolean=} noSafe */
-function setValue(ptr, value, type, noSafe) {
- type = type || 'i8';
- if (type.charAt(type.length-1) === '*') type = 'i32';
- switch (type) {
- case 'i1': HEAP8[((ptr)>>0)] = value; break;
- case 'i8': HEAP8[((ptr)>>0)] = value; break;
- case 'i16': HEAP16[((ptr)>>1)] = value; break;
- case 'i32': HEAP32[((ptr)>>2)] = value; break;
- case 'i64': (tempI64 = [value>>>0,(tempDouble=value,(+(Math.abs(tempDouble))) >= 1.0 ? (tempDouble > 0.0 ? ((Math.min((+(Math.floor((tempDouble)/4294967296.0))), 4294967295.0))|0)>>>0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble)))>>>0))/4294967296.0)))))>>>0) : 0)],HEAP32[((ptr)>>2)] = tempI64[0],HEAP32[(((ptr)+(4))>>2)] = tempI64[1]); break;
- case 'float': HEAPF32[((ptr)>>2)] = value; break;
- case 'double': HEAPF64[((ptr)>>3)] = value; break;
- default: abort('invalid type for setValue: ' + type);
- }
-}
-
-/** @param {number} ptr
- @param {string} type
- @param {number|boolean=} noSafe */
-function getValue(ptr, type, noSafe) {
- type = type || 'i8';
- if (type.charAt(type.length-1) === '*') type = 'i32';
- switch (type) {
- case 'i1': return HEAP8[((ptr)>>0)];
- case 'i8': return HEAP8[((ptr)>>0)];
- case 'i16': return HEAP16[((ptr)>>1)];
- case 'i32': return HEAP32[((ptr)>>2)];
- case 'i64': return HEAP32[((ptr)>>2)];
- case 'float': return HEAPF32[((ptr)>>2)];
- case 'double': return Number(HEAPF64[((ptr)>>3)]);
- default: abort('invalid type for getValue: ' + type);
- }
- return null;
-}
-
-// end include: runtime_safe_heap.js
-// Wasm globals
-
-var wasmMemory;
-
-//========================================
-// Runtime essentials
-//========================================
-
-// whether we are quitting the application. no code should run after this.
-// set in exit() and abort()
-var ABORT = false;
-
-// set by exit() and abort(). Passed to 'onExit' handler.
-// NOTE: This is also used as the process return code code in shell environments
-// but only when noExitRuntime is false.
-var EXITSTATUS;
-
-/** @type {function(*, string=)} */
-function assert(condition, text) {
- if (!condition) {
- abort('Assertion failed: ' + text);
- }
-}
-
-// Returns the C function with a specified identifier (for C++, you need to do manual name mangling)
-function getCFunc(ident) {
- var func = Module['_' + ident]; // closure exported function
- assert(func, 'Cannot call unknown function ' + ident + ', make sure it is exported');
- return func;
-}
-
-// C calling interface.
-/** @param {string|null=} returnType
- @param {Array=} argTypes
- @param {Arguments|Array=} args
- @param {Object=} opts */
-function ccall(ident, returnType, argTypes, args, opts) {
- // For fast lookup of conversion functions
- var toC = {
- 'string': function(str) {
- var ret = 0;
- if (str !== null && str !== undefined && str !== 0) { // null string
- // at most 4 bytes per UTF-8 code point, +1 for the trailing '\0'
- var len = (str.length << 2) + 1;
- ret = stackAlloc(len);
- stringToUTF8(str, ret, len);
- }
- return ret;
- },
- 'array': function(arr) {
- var ret = stackAlloc(arr.length);
- writeArrayToMemory(arr, ret);
- return ret;
- }
- };
-
- function convertReturnValue(ret) {
- if (returnType === 'string') return UTF8ToString(ret);
- if (returnType === 'boolean') return Boolean(ret);
- return ret;
- }
-
- var func = getCFunc(ident);
- var cArgs = [];
- var stack = 0;
- if (args) {
- for (var i = 0; i < args.length; i++) {
- var converter = toC[argTypes[i]];
- if (converter) {
- if (stack === 0) stack = stackSave();
- cArgs[i] = converter(args[i]);
- } else {
- cArgs[i] = args[i];
- }
- }
- }
- var ret = func.apply(null, cArgs);
- function onDone(ret) {
- runtimeKeepalivePop();
- if (stack !== 0) stackRestore(stack);
- return convertReturnValue(ret);
- }
- runtimeKeepalivePush();
- var asyncMode = opts && opts.async;
- // Check if we started an async operation just now.
- if (Asyncify.currData) {
- // If so, the WASM function ran asynchronous and unwound its stack.
- // We need to return a Promise that resolves the return value
- // once the stack is rewound and execution finishes.
- return Asyncify.whenDone().then(onDone);
- }
-
- ret = onDone(ret);
- // If this is an async ccall, ensure we return a promise
- if (asyncMode) return Promise.resolve(ret);
- return ret;
-}
-
-/** @param {string=} returnType
- @param {Array=} argTypes
- @param {Object=} opts */
-function cwrap(ident, returnType, argTypes, opts) {
- argTypes = argTypes || [];
- // When the function takes numbers and returns a number, we can just return
- // the original function
- var numericArgs = argTypes.every(function(type){ return type === 'number'});
- var numericRet = returnType !== 'string';
- if (numericRet && numericArgs && !opts) {
- return getCFunc(ident);
- }
- return function() {
- return ccall(ident, returnType, argTypes, arguments, opts);
- }
-}
-
-var ALLOC_NORMAL = 0; // Tries to use _malloc()
-var ALLOC_STACK = 1; // Lives for the duration of the current function call
-
-// allocate(): This is for internal use. You can use it yourself as well, but the interface
-// is a little tricky (see docs right below). The reason is that it is optimized
-// for multiple syntaxes to save space in generated code. So you should
-// normally not use allocate(), and instead allocate memory using _malloc(),
-// initialize it with setValue(), and so forth.
-// @slab: An array of data.
-// @allocator: How to allocate memory, see ALLOC_*
-/** @type {function((Uint8Array|Array), number)} */
-function allocate(slab, allocator) {
- var ret;
-
- if (allocator == ALLOC_STACK) {
- ret = stackAlloc(slab.length);
- } else {
- ret = _malloc(slab.length);
- }
-
- if (slab.subarray || slab.slice) {
- HEAPU8.set(/** @type {!Uint8Array} */(slab), ret);
- } else {
- HEAPU8.set(new Uint8Array(slab), ret);
- }
- return ret;
-}
-
-// include: runtime_strings.js
-
-
-// runtime_strings.js: Strings related runtime functions that are part of both MINIMAL_RUNTIME and regular runtime.
-
-// Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the given array that contains uint8 values, returns
-// a copy of that string as a Javascript String object.
-
-var UTF8Decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf8') : undefined;
-
-/**
- * @param {number} idx
- * @param {number=} maxBytesToRead
- * @return {string}
- */
-function UTF8ArrayToString(heap, idx, maxBytesToRead) {
- var endIdx = idx + maxBytesToRead;
- var endPtr = idx;
- // TextDecoder needs to know the byte length in advance, it doesn't stop on null terminator by itself.
- // Also, use the length info to avoid running tiny strings through TextDecoder, since .subarray() allocates garbage.
- // (As a tiny code save trick, compare endPtr against endIdx using a negation, so that undefined means Infinity)
- while (heap[endPtr] && !(endPtr >= endIdx)) ++endPtr;
-
- if (endPtr - idx > 16 && heap.subarray && UTF8Decoder) {
- return UTF8Decoder.decode(heap.subarray(idx, endPtr));
- } else {
- var str = '';
- // If building with TextDecoder, we have already computed the string length above, so test loop end condition against that
- while (idx < endPtr) {
- // For UTF8 byte structure, see:
- // http://en.wikipedia.org/wiki/UTF-8#Description
- // https://www.ietf.org/rfc/rfc2279.txt
- // https://tools.ietf.org/html/rfc3629
- var u0 = heap[idx++];
- if (!(u0 & 0x80)) { str += String.fromCharCode(u0); continue; }
- var u1 = heap[idx++] & 63;
- if ((u0 & 0xE0) == 0xC0) { str += String.fromCharCode(((u0 & 31) << 6) | u1); continue; }
- var u2 = heap[idx++] & 63;
- if ((u0 & 0xF0) == 0xE0) {
- u0 = ((u0 & 15) << 12) | (u1 << 6) | u2;
- } else {
- u0 = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | (heap[idx++] & 63);
- }
-
- if (u0 < 0x10000) {
- str += String.fromCharCode(u0);
- } else {
- var ch = u0 - 0x10000;
- str += String.fromCharCode(0xD800 | (ch >> 10), 0xDC00 | (ch & 0x3FF));
- }
- }
- }
- return str;
-}
-
-// Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the emscripten HEAP, returns a
-// copy of that string as a Javascript String object.
-// maxBytesToRead: an optional length that specifies the maximum number of bytes to read. You can omit
-// this parameter to scan the string until the first \0 byte. If maxBytesToRead is
-// passed, and the string at [ptr, ptr+maxBytesToReadr[ contains a null byte in the
-// middle, then the string will cut short at that byte index (i.e. maxBytesToRead will
-// not produce a string of exact length [ptr, ptr+maxBytesToRead[)
-// N.B. mixing frequent uses of UTF8ToString() with and without maxBytesToRead may
-// throw JS JIT optimizations off, so it is worth to consider consistently using one
-// style or the other.
-/**
- * @param {number} ptr
- * @param {number=} maxBytesToRead
- * @return {string}
- */
-function UTF8ToString(ptr, maxBytesToRead) {
- ;
- return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : '';
-}
-
-// Copies the given Javascript String object 'str' to the given byte array at address 'outIdx',
-// encoded in UTF8 form and null-terminated. The copy will require at most str.length*4+1 bytes of space in the HEAP.
-// Use the function lengthBytesUTF8 to compute the exact number of bytes (excluding null terminator) that this function will write.
-// Parameters:
-// str: the Javascript string to copy.
-// heap: the array to copy to. Each index in this array is assumed to be one 8-byte element.
-// outIdx: The starting offset in the array to begin the copying.
-// maxBytesToWrite: The maximum number of bytes this function can write to the array.
-// This count should include the null terminator,
-// i.e. if maxBytesToWrite=1, only the null terminator will be written and nothing else.
-// maxBytesToWrite=0 does not write any bytes to the output, not even the null terminator.
-// Returns the number of bytes written, EXCLUDING the null terminator.
-
-function stringToUTF8Array(str, heap, outIdx, maxBytesToWrite) {
- if (!(maxBytesToWrite > 0)) // Parameter maxBytesToWrite is not optional. Negative values, 0, null, undefined and false each don't write out any bytes.
- return 0;
-
- var startIdx = outIdx;
- var endIdx = outIdx + maxBytesToWrite - 1; // -1 for string null terminator.
- for (var i = 0; i < str.length; ++i) {
- // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code unit, not a Unicode code point of the character! So decode UTF16->UTF32->UTF8.
- // See http://unicode.org/faq/utf_bom.html#utf16-3
- // For UTF8 byte structure, see http://en.wikipedia.org/wiki/UTF-8#Description and https://www.ietf.org/rfc/rfc2279.txt and https://tools.ietf.org/html/rfc3629
- var u = str.charCodeAt(i); // possibly a lead surrogate
- if (u >= 0xD800 && u <= 0xDFFF) {
- var u1 = str.charCodeAt(++i);
- u = 0x10000 + ((u & 0x3FF) << 10) | (u1 & 0x3FF);
- }
- if (u <= 0x7F) {
- if (outIdx >= endIdx) break;
- heap[outIdx++] = u;
- } else if (u <= 0x7FF) {
- if (outIdx + 1 >= endIdx) break;
- heap[outIdx++] = 0xC0 | (u >> 6);
- heap[outIdx++] = 0x80 | (u & 63);
- } else if (u <= 0xFFFF) {
- if (outIdx + 2 >= endIdx) break;
- heap[outIdx++] = 0xE0 | (u >> 12);
- heap[outIdx++] = 0x80 | ((u >> 6) & 63);
- heap[outIdx++] = 0x80 | (u & 63);
- } else {
- if (outIdx + 3 >= endIdx) break;
- heap[outIdx++] = 0xF0 | (u >> 18);
- heap[outIdx++] = 0x80 | ((u >> 12) & 63);
- heap[outIdx++] = 0x80 | ((u >> 6) & 63);
- heap[outIdx++] = 0x80 | (u & 63);
- }
- }
- // Null-terminate the pointer to the buffer.
- heap[outIdx] = 0;
- return outIdx - startIdx;
-}
-
-// Copies the given Javascript String object 'str' to the emscripten HEAP at address 'outPtr',
-// null-terminated and encoded in UTF8 form. The copy will require at most str.length*4+1 bytes of space in the HEAP.
-// Use the function lengthBytesUTF8 to compute the exact number of bytes (excluding null terminator) that this function will write.
-// Returns the number of bytes written, EXCLUDING the null terminator.
-
-function stringToUTF8(str, outPtr, maxBytesToWrite) {
- return stringToUTF8Array(str, HEAPU8,outPtr, maxBytesToWrite);
-}
-
-// Returns the number of bytes the given Javascript string takes if encoded as a UTF8 byte array, EXCLUDING the null terminator byte.
-function lengthBytesUTF8(str) {
- var len = 0;
- for (var i = 0; i < str.length; ++i) {
- // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code unit, not a Unicode code point of the character! So decode UTF16->UTF32->UTF8.
- // See http://unicode.org/faq/utf_bom.html#utf16-3
- var u = str.charCodeAt(i); // possibly a lead surrogate
- if (u >= 0xD800 && u <= 0xDFFF) u = 0x10000 + ((u & 0x3FF) << 10) | (str.charCodeAt(++i) & 0x3FF);
- if (u <= 0x7F) ++len;
- else if (u <= 0x7FF) len += 2;
- else if (u <= 0xFFFF) len += 3;
- else len += 4;
- }
- return len;
-}
-
-// end include: runtime_strings.js
-// include: runtime_strings_extra.js
-
-
-// runtime_strings_extra.js: Strings related runtime functions that are available only in regular runtime.
-
-// Given a pointer 'ptr' to a null-terminated ASCII-encoded string in the emscripten HEAP, returns
-// a copy of that string as a Javascript String object.
-
-function AsciiToString(ptr) {
- var str = '';
- while (1) {
- var ch = HEAPU8[((ptr++)>>0)];
- if (!ch) return str;
- str += String.fromCharCode(ch);
- }
-}
-
-// Copies the given Javascript String object 'str' to the emscripten HEAP at address 'outPtr',
-// null-terminated and encoded in ASCII form. The copy will require at most str.length+1 bytes of space in the HEAP.
-
-function stringToAscii(str, outPtr) {
- return writeAsciiToMemory(str, outPtr, false);
-}
-
-// Given a pointer 'ptr' to a null-terminated UTF16LE-encoded string in the emscripten HEAP, returns
-// a copy of that string as a Javascript String object.
-
-var UTF16Decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-16le') : undefined;
-
-function UTF16ToString(ptr, maxBytesToRead) {
- var endPtr = ptr;
- // TextDecoder needs to know the byte length in advance, it doesn't stop on null terminator by itself.
- // Also, use the length info to avoid running tiny strings through TextDecoder, since .subarray() allocates garbage.
- var idx = endPtr >> 1;
- var maxIdx = idx + maxBytesToRead / 2;
- // If maxBytesToRead is not passed explicitly, it will be undefined, and this
- // will always evaluate to true. This saves on code size.
- while (!(idx >= maxIdx) && HEAPU16[idx]) ++idx;
- endPtr = idx << 1;
-
- if (endPtr - ptr > 32 && UTF16Decoder) {
- return UTF16Decoder.decode(HEAPU8.subarray(ptr, endPtr));
- } else {
- var str = '';
-
- // If maxBytesToRead is not passed explicitly, it will be undefined, and the for-loop's condition
- // will always evaluate to true. The loop is then terminated on the first null char.
- for (var i = 0; !(i >= maxBytesToRead / 2); ++i) {
- var codeUnit = HEAP16[(((ptr)+(i*2))>>1)];
- if (codeUnit == 0) break;
- // fromCharCode constructs a character from a UTF-16 code unit, so we can pass the UTF16 string right through.
- str += String.fromCharCode(codeUnit);
- }
-
- return str;
- }
-}
-
-// Copies the given Javascript String object 'str' to the emscripten HEAP at address 'outPtr',
-// null-terminated and encoded in UTF16 form. The copy will require at most str.length*4+2 bytes of space in the HEAP.
-// Use the function lengthBytesUTF16() to compute the exact number of bytes (excluding null terminator) that this function will write.
-// Parameters:
-// str: the Javascript string to copy.
-// outPtr: Byte address in Emscripten HEAP where to write the string to.
-// maxBytesToWrite: The maximum number of bytes this function can write to the array. This count should include the null
-// terminator, i.e. if maxBytesToWrite=2, only the null terminator will be written and nothing else.
-// maxBytesToWrite<2 does not write any bytes to the output, not even the null terminator.
-// Returns the number of bytes written, EXCLUDING the null terminator.
-
-function stringToUTF16(str, outPtr, maxBytesToWrite) {
- // Backwards compatibility: if max bytes is not specified, assume unsafe unbounded write is allowed.
- if (maxBytesToWrite === undefined) {
- maxBytesToWrite = 0x7FFFFFFF;
- }
- if (maxBytesToWrite < 2) return 0;
- maxBytesToWrite -= 2; // Null terminator.
- var startPtr = outPtr;
- var numCharsToWrite = (maxBytesToWrite < str.length*2) ? (maxBytesToWrite / 2) : str.length;
- for (var i = 0; i < numCharsToWrite; ++i) {
- // charCodeAt returns a UTF-16 encoded code unit, so it can be directly written to the HEAP.
- var codeUnit = str.charCodeAt(i); // possibly a lead surrogate
- HEAP16[((outPtr)>>1)] = codeUnit;
- outPtr += 2;
- }
- // Null-terminate the pointer to the HEAP.
- HEAP16[((outPtr)>>1)] = 0;
- return outPtr - startPtr;
-}
-
-// Returns the number of bytes the given Javascript string takes if encoded as a UTF16 byte array, EXCLUDING the null terminator byte.
-
-function lengthBytesUTF16(str) {
- return str.length*2;
-}
-
-function UTF32ToString(ptr, maxBytesToRead) {
- var i = 0;
-
- var str = '';
- // If maxBytesToRead is not passed explicitly, it will be undefined, and this
- // will always evaluate to true. This saves on code size.
- while (!(i >= maxBytesToRead / 4)) {
- var utf32 = HEAP32[(((ptr)+(i*4))>>2)];
- if (utf32 == 0) break;
- ++i;
- // Gotcha: fromCharCode constructs a character from a UTF-16 encoded code (pair), not from a Unicode code point! So encode the code point to UTF-16 for constructing.
- // See http://unicode.org/faq/utf_bom.html#utf16-3
- if (utf32 >= 0x10000) {
- var ch = utf32 - 0x10000;
- str += String.fromCharCode(0xD800 | (ch >> 10), 0xDC00 | (ch & 0x3FF));
- } else {
- str += String.fromCharCode(utf32);
- }
- }
- return str;
-}
-
-// Copies the given Javascript String object 'str' to the emscripten HEAP at address 'outPtr',
-// null-terminated and encoded in UTF32 form. The copy will require at most str.length*4+4 bytes of space in the HEAP.
-// Use the function lengthBytesUTF32() to compute the exact number of bytes (excluding null terminator) that this function will write.
-// Parameters:
-// str: the Javascript string to copy.
-// outPtr: Byte address in Emscripten HEAP where to write the string to.
-// maxBytesToWrite: The maximum number of bytes this function can write to the array. This count should include the null
-// terminator, i.e. if maxBytesToWrite=4, only the null terminator will be written and nothing else.
-// maxBytesToWrite<4 does not write any bytes to the output, not even the null terminator.
-// Returns the number of bytes written, EXCLUDING the null terminator.
-
-function stringToUTF32(str, outPtr, maxBytesToWrite) {
- // Backwards compatibility: if max bytes is not specified, assume unsafe unbounded write is allowed.
- if (maxBytesToWrite === undefined) {
- maxBytesToWrite = 0x7FFFFFFF;
- }
- if (maxBytesToWrite < 4) return 0;
- var startPtr = outPtr;
- var endPtr = startPtr + maxBytesToWrite - 4;
- for (var i = 0; i < str.length; ++i) {
- // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code unit, not a Unicode code point of the character! We must decode the string to UTF-32 to the heap.
- // See http://unicode.org/faq/utf_bom.html#utf16-3
- var codeUnit = str.charCodeAt(i); // possibly a lead surrogate
- if (codeUnit >= 0xD800 && codeUnit <= 0xDFFF) {
- var trailSurrogate = str.charCodeAt(++i);
- codeUnit = 0x10000 + ((codeUnit & 0x3FF) << 10) | (trailSurrogate & 0x3FF);
- }
- HEAP32[((outPtr)>>2)] = codeUnit;
- outPtr += 4;
- if (outPtr + 4 > endPtr) break;
- }
- // Null-terminate the pointer to the HEAP.
- HEAP32[((outPtr)>>2)] = 0;
- return outPtr - startPtr;
-}
-
-// Returns the number of bytes the given Javascript string takes if encoded as a UTF16 byte array, EXCLUDING the null terminator byte.
-
-function lengthBytesUTF32(str) {
- var len = 0;
- for (var i = 0; i < str.length; ++i) {
- // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code unit, not a Unicode code point of the character! We must decode the string to UTF-32 to the heap.
- // See http://unicode.org/faq/utf_bom.html#utf16-3
- var codeUnit = str.charCodeAt(i);
- if (codeUnit >= 0xD800 && codeUnit <= 0xDFFF) ++i; // possibly a lead surrogate, so skip over the tail surrogate.
- len += 4;
- }
-
- return len;
-}
-
-// Allocate heap space for a JS string, and write it there.
-// It is the responsibility of the caller to free() that memory.
-function allocateUTF8(str) {
- var size = lengthBytesUTF8(str) + 1;
- var ret = _malloc(size);
- if (ret) stringToUTF8Array(str, HEAP8, ret, size);
- return ret;
-}
-
-// Allocate stack space for a JS string, and write it there.
-function allocateUTF8OnStack(str) {
- var size = lengthBytesUTF8(str) + 1;
- var ret = stackAlloc(size);
- stringToUTF8Array(str, HEAP8, ret, size);
- return ret;
-}
-
-// Deprecated: This function should not be called because it is unsafe and does not provide
-// a maximum length limit of how many bytes it is allowed to write. Prefer calling the
-// function stringToUTF8Array() instead, which takes in a maximum length that can be used
-// to be secure from out of bounds writes.
-/** @deprecated
- @param {boolean=} dontAddNull */
-function writeStringToMemory(string, buffer, dontAddNull) {
- warnOnce('writeStringToMemory is deprecated and should not be called! Use stringToUTF8() instead!');
-
- var /** @type {number} */ lastChar, /** @type {number} */ end;
- if (dontAddNull) {
- // stringToUTF8Array always appends null. If we don't want to do that, remember the
- // character that existed at the location where the null will be placed, and restore
- // that after the write (below).
- end = buffer + lengthBytesUTF8(string);
- lastChar = HEAP8[end];
- }
- stringToUTF8(string, buffer, Infinity);
- if (dontAddNull) HEAP8[end] = lastChar; // Restore the value under the null character.
-}
-
-function writeArrayToMemory(array, buffer) {
- HEAP8.set(array, buffer);
-}
-
-/** @param {boolean=} dontAddNull */
-function writeAsciiToMemory(str, buffer, dontAddNull) {
- for (var i = 0; i < str.length; ++i) {
- HEAP8[((buffer++)>>0)] = str.charCodeAt(i);
- }
- // Null-terminate the pointer to the HEAP.
- if (!dontAddNull) HEAP8[((buffer)>>0)] = 0;
-}
-
-// end include: runtime_strings_extra.js
-// Memory management
-
-function alignUp(x, multiple) {
- if (x % multiple > 0) {
- x += multiple - (x % multiple);
- }
- return x;
-}
-
-var HEAP,
-/** @type {ArrayBuffer} */
- buffer,
-/** @type {Int8Array} */
- HEAP8,
-/** @type {Uint8Array} */
- HEAPU8,
-/** @type {Int16Array} */
- HEAP16,
-/** @type {Uint16Array} */
- HEAPU16,
-/** @type {Int32Array} */
- HEAP32,
-/** @type {Uint32Array} */
- HEAPU32,
-/** @type {Float32Array} */
- HEAPF32,
-/** @type {Float64Array} */
- HEAPF64;
-
-function updateGlobalBufferAndViews(buf) {
- buffer = buf;
- Module['HEAP8'] = HEAP8 = new Int8Array(buf);
- Module['HEAP16'] = HEAP16 = new Int16Array(buf);
- Module['HEAP32'] = HEAP32 = new Int32Array(buf);
- Module['HEAPU8'] = HEAPU8 = new Uint8Array(buf);
- Module['HEAPU16'] = HEAPU16 = new Uint16Array(buf);
- Module['HEAPU32'] = HEAPU32 = new Uint32Array(buf);
- Module['HEAPF32'] = HEAPF32 = new Float32Array(buf);
- Module['HEAPF64'] = HEAPF64 = new Float64Array(buf);
-}
-
-var TOTAL_STACK = 5242880;
-
-var INITIAL_MEMORY = Module['INITIAL_MEMORY'] || 33554432;
-
-// include: runtime_init_table.js
-// In regular non-RELOCATABLE mode the table is exported
-// from the wasm module and this will be assigned once
-// the exports are available.
-var wasmTable;
-
-// end include: runtime_init_table.js
-// include: runtime_stack_check.js
-
-
-// end include: runtime_stack_check.js
-// include: runtime_assertions.js
-
-
-// end include: runtime_assertions.js
-var __ATPRERUN__ = []; // functions called before the runtime is initialized
-var __ATINIT__ = []; // functions called during startup
-var __ATEXIT__ = []; // functions called during shutdown
-var __ATPOSTRUN__ = []; // functions called after the main() is called
-
-var runtimeInitialized = false;
-var runtimeExited = false;
-var runtimeKeepaliveCounter = 0;
-
-function keepRuntimeAlive() {
- return noExitRuntime || runtimeKeepaliveCounter > 0;
-}
-
-function preRun() {
-
- if (Module['preRun']) {
- if (typeof Module['preRun'] == 'function') Module['preRun'] = [Module['preRun']];
- while (Module['preRun'].length) {
- addOnPreRun(Module['preRun'].shift());
- }
- }
-
- callRuntimeCallbacks(__ATPRERUN__);
-}
-
-function initRuntime() {
- runtimeInitialized = true;
-
-
- callRuntimeCallbacks(__ATINIT__);
-}
-
-function exitRuntime() {
- runtimeExited = true;
-}
-
-function postRun() {
-
- if (Module['postRun']) {
- if (typeof Module['postRun'] == 'function') Module['postRun'] = [Module['postRun']];
- while (Module['postRun'].length) {
- addOnPostRun(Module['postRun'].shift());
- }
- }
-
- callRuntimeCallbacks(__ATPOSTRUN__);
-}
-
-function addOnPreRun(cb) {
- __ATPRERUN__.unshift(cb);
-}
-
-function addOnInit(cb) {
- __ATINIT__.unshift(cb);
-}
-
-function addOnExit(cb) {
-}
-
-function addOnPostRun(cb) {
- __ATPOSTRUN__.unshift(cb);
-}
-
-// include: runtime_math.js
-
-
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/imul
-// || MIN_NODE_VERSION < 0.12
-// check for imul support, and also for correctness ( https://bugs.webkit.org/show_bug.cgi?id=126345 )
-if (!Math.imul || Math.imul(0xffffffff, 5) !== -5) Math.imul = function imul(a, b) {
- var ah = a >>> 16;
- var al = a & 0xffff;
- var bh = b >>> 16;
- var bl = b & 0xffff;
- return (al*bl + ((ah*bl + al*bh) << 16))|0;
-};
-
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/fround
-
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32
-
-// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/trunc
-
-// end include: runtime_math.js
-// A counter of dependencies for calling run(). If we need to
-// do asynchronous work before running, increment this and
-// decrement it. Incrementing must happen in a place like
-// Module.preRun (used by emcc to add file preloading).
-// Note that you can add dependencies in preRun, even though
-// it happens right before run - run will be postponed until
-// the dependencies are met.
-var runDependencies = 0;
-var runDependencyWatcher = null;
-var dependenciesFulfilled = null; // overridden to take different actions when all run dependencies are fulfilled
-
-function getUniqueRunDependency(id) {
- return id;
-}
-
-function addRunDependency(id) {
- runDependencies++;
-
- if (Module['monitorRunDependencies']) {
- Module['monitorRunDependencies'](runDependencies);
- }
-
-}
-
-function removeRunDependency(id) {
- runDependencies--;
-
- if (Module['monitorRunDependencies']) {
- Module['monitorRunDependencies'](runDependencies);
- }
-
- if (runDependencies == 0) {
- if (runDependencyWatcher !== null) {
- clearInterval(runDependencyWatcher);
- runDependencyWatcher = null;
- }
- if (dependenciesFulfilled) {
- var callback = dependenciesFulfilled;
- dependenciesFulfilled = null;
- callback(); // can add another dependenciesFulfilled
- }
- }
-}
-
-Module["preloadedImages"] = {}; // maps url to image data
-Module["preloadedAudios"] = {}; // maps url to audio data
-
-/** @param {string|number=} what */
-function abort(what) {
- {
- if (Module['onAbort']) {
- Module['onAbort'](what);
- }
- }
-
- what = 'Aborted(' + what + ')';
- // TODO(sbc): Should we remove printing and leave it up to whoever
- // catches the exception?
- err(what);
-
- ABORT = true;
- EXITSTATUS = 1;
-
- what += '. Build with -s ASSERTIONS=1 for more info.';
-
- // Use a wasm runtime error, because a JS error might be seen as a foreign
- // exception, which means we'd run destructors on it. We need the error to
- // simply make the program stop.
- var e = new WebAssembly.RuntimeError(what);
-
- // Throw the error whether or not MODULARIZE is set because abort is used
- // in code paths apart from instantiation where an exception is expected
- // to be thrown when abort is called.
- throw e;
-}
-
-// {{MEM_INITIALIZER}}
-
-// include: memoryprofiler.js
-
-
-// end include: memoryprofiler.js
-// include: URIUtils.js
-
-
-// Prefix of data URIs emitted by SINGLE_FILE and related options.
-var dataURIPrefix = 'data:application/octet-stream;base64,';
-
-// Indicates whether filename is a base64 data URI.
-function isDataURI(filename) {
- // Prefix of data URIs emitted by SINGLE_FILE and related options.
- return filename.startsWith(dataURIPrefix);
-}
-
-// Indicates whether filename is delivered via file protocol (as opposed to http/https)
-function isFileURI(filename) {
- return filename.startsWith('file://');
-}
-
-// end include: URIUtils.js
-var wasmBinaryFile = VTS_WASM_URL;
-
-function getBinary(file) {
- try {
- if (file == wasmBinaryFile && wasmBinary) {
- return new Uint8Array(wasmBinary);
- }
- if (readBinary) {
- return readBinary(file);
- } else {
- throw "both async and sync fetching of the wasm failed";
- }
- }
- catch (err) {
- abort(err);
- }
-}
-
-function getBinaryPromise() {
- // If we don't have the binary yet, try to to load it asynchronously.
- // Fetch has some additional restrictions over XHR, like it can't be used on a file:// url.
- // See https://github.com/github/fetch/pull/92#issuecomment-140665932
- // Cordova or Electron apps are typically loaded from a file:// url.
- // So use fetch if it is available and the url is not a file, otherwise fall back to XHR.
- if (!wasmBinary && (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER)) {
- if (typeof fetch === 'function'
- ) {
- return fetch(wasmBinaryFile, { credentials: 'same-origin' }).then(function(response) {
- if (!response['ok']) {
- throw "failed to load wasm binary file at '" + wasmBinaryFile + "'";
- }
- return response['arrayBuffer']();
- }).catch(function () {
- return getBinary(wasmBinaryFile);
- });
- }
- }
-
- // Otherwise, getBinary should be able to get it synchronously
- return Promise.resolve().then(function() { return getBinary(wasmBinaryFile); });
-}
-
-// Create the wasm instance.
-// Receives the wasm imports, returns the exports.
-function createWasm() {
- // prepare imports
- var info = {
- 'env': asmLibraryArg,
- 'wasi_snapshot_preview1': asmLibraryArg,
- };
- // Load the wasm module and create an instance of using native support in the JS engine.
- // handle a generated wasm instance, receiving its exports and
- // performing other necessary setup
- /** @param {WebAssembly.Module=} module*/
- function receiveInstance(instance, module) {
- var exports = instance.exports;
-
- exports = Asyncify.instrumentWasmExports(exports);
-
- Module['asm'] = exports;
-
- wasmMemory = Module['asm']['memory'];
- updateGlobalBufferAndViews(wasmMemory.buffer);
-
- wasmTable = Module['asm']['__indirect_function_table'];
-
- addOnInit(Module['asm']['__wasm_call_ctors']);
-
- removeRunDependency('wasm-instantiate');
- }
- // we can't run yet (except in a pthread, where we have a custom sync instantiator)
- addRunDependency('wasm-instantiate');
-
- // Prefer streaming instantiation if available.
- function receiveInstantiationResult(result) {
- // 'result' is a ResultObject object which has both the module and instance.
- // receiveInstance() will swap in the exports (to Module.asm) so they can be called
- // TODO: Due to Closure regression https://github.com/google/closure-compiler/issues/3193, the above line no longer optimizes out down to the following line.
- // When the regression is fixed, can restore the above USE_PTHREADS-enabled path.
- receiveInstance(result['instance']);
- }
-
- function instantiateArrayBuffer(receiver) {
- return getBinaryPromise().then(function(binary) {
- return WebAssembly.instantiate(binary, info);
- }).then(function (instance) {
- return instance;
- }).then(receiver, function(reason) {
- err('failed to asynchronously prepare wasm: ' + reason);
-
- abort(reason);
- });
- }
-
- function instantiateAsync() {
- if (!wasmBinary &&
- typeof WebAssembly.instantiateStreaming === 'function' &&
- !isDataURI(wasmBinaryFile) &&
- typeof fetch === 'function') {
- return fetch(wasmBinaryFile, { credentials: 'same-origin' }).then(function (response) {
- var result = WebAssembly.instantiateStreaming(response, info);
-
- return result.then(
- receiveInstantiationResult,
- function(reason) {
- // We expect the most common failure cause to be a bad MIME type for the binary,
- // in which case falling back to ArrayBuffer instantiation should work.
- err('wasm streaming compile failed: ' + reason);
- err('falling back to ArrayBuffer instantiation');
- return instantiateArrayBuffer(receiveInstantiationResult);
- });
- });
- } else {
- return instantiateArrayBuffer(receiveInstantiationResult);
- }
- }
-
- // User shell pages can write their own Module.instantiateWasm = function(imports, successCallback) callback
- // to manually instantiate the Wasm module themselves. This allows pages to run the instantiation parallel
- // to any other async startup actions they are performing.
- if (Module['instantiateWasm']) {
- try {
- var exports = Module['instantiateWasm'](info, receiveInstance);
- exports = Asyncify.instrumentWasmExports(exports);
- return exports;
- } catch(e) {
- err('Module.instantiateWasm callback failed with error: ' + e);
- return false;
- }
- }
-
- instantiateAsync();
- return {}; // no exports yet; we'll fill them in later
-}
-
-// Globals used by JS i64 conversions (see makeSetValue)
-var tempDouble;
-var tempI64;
-
-// === Body ===
-
-var ASM_CONSTS = {
- 434420: function($0, $1, $2) {wasm_ffmpeg_error_report($0, $1, $2);},
- 434460: function($0, $1) {wasm_isaac_generate($0, $1);},
- 434491: function($0, $1, $2) {return wasm_ffmpeg_fwrite($0, $1, $2);},
- 434532: function($0, $1) {wasm_ffmpeg_fsize($0, $1);},
- 434561: function($0, $1, $2, $3, $4) {wasm_ffmpeg_fseek($0, $1, $2, $3, $4);},
- 434602: function($0, $1) {wasm_ffmpeg_fclose($0, $1);}
-};
-function __asyncjs__wasm_ffmpeg_fopen_sync(filename,filelen,acc){ return Asyncify.handleAsync(async () => { const ret = await wasm_ffmpeg_fopen(filename, filelen, acc); return ret; }); }
-function __asyncjs__wasm_ffmpeg_fread_sync(fd,buf,size,ffindex){ return Asyncify.handleAsync(async () => { const ret = await wasm_ffmpeg_fread(fd, buf, size, ffindex); return ret; }); }
-
-
-
-
-
- function callRuntimeCallbacks(callbacks) {
- while (callbacks.length > 0) {
- var callback = callbacks.shift();
- if (typeof callback == 'function') {
- callback(Module); // Pass the module as the first argument.
- continue;
- }
- var func = callback.func;
- if (typeof func === 'number') {
- if (callback.arg === undefined) {
- (function() { dynCall_v.call(null, func); })();
- } else {
- (function(a1) { dynCall_vi.apply(null, [func, a1]); })(callback.arg);
- }
- } else {
- func(callback.arg === undefined ? null : callback.arg);
- }
- }
- }
-
- function withStackSave(f) {
- var stack = stackSave();
- var ret = f();
- stackRestore(stack);
- return ret;
- }
- function demangle(func) {
- return func;
- }
-
- function demangleAll(text) {
- var regex =
- /\b_Z[\w\d_]+/g;
- return text.replace(regex,
- function(x) {
- var y = demangle(x);
- return x === y ? x : (y + ' [' + x + ']');
- });
- }
-
- var wasmTableMirror = [];
- function getWasmTableEntry(funcPtr) {
- var func = wasmTableMirror[funcPtr];
- if (!func) {
- if (funcPtr >= wasmTableMirror.length) wasmTableMirror.length = funcPtr + 1;
- wasmTableMirror[funcPtr] = func = wasmTable.get(funcPtr);
- }
- return func;
- }
-
- function handleException(e) {
- // Certain exception types we do not treat as errors since they are used for
- // internal control flow.
- // 1. ExitStatus, which is thrown by exit()
- // 2. "unwind", which is thrown by emscripten_unwind_to_js_event_loop() and others
- // that wish to return to JS event loop.
- if (e instanceof ExitStatus || e == 'unwind') {
- return EXITSTATUS;
- }
- quit_(1, e);
- }
-
- function jsStackTrace() {
- var error = new Error();
- if (!error.stack) {
- // IE10+ special cases: It does have callstack info, but it is only populated if an Error object is thrown,
- // so try that as a special-case.
- try {
- throw new Error();
- } catch(e) {
- error = e;
- }
- if (!error.stack) {
- return '(no stack trace available)';
- }
- }
- return error.stack.toString();
- }
-
- function setWasmTableEntry(idx, func) {
- wasmTable.set(idx, func);
- wasmTableMirror[idx] = func;
- }
-
- function stackTrace() {
- var js = jsStackTrace();
- if (Module['extraStackTrace']) js += '\n' + Module['extraStackTrace']();
- return demangleAll(js);
- }
-
- function ___cxa_allocate_exception(size) {
- // Thrown object is prepended by exception metadata block
- return _malloc(size + 16) + 16;
- }
-
- function _atexit(func, arg) {
- }
- function ___cxa_atexit(a0,a1
- ) {
- return _atexit(a0,a1);
- }
-
- function ExceptionInfo(excPtr) {
- this.excPtr = excPtr;
- this.ptr = excPtr - 16;
-
- this.set_type = function(type) {
- HEAP32[(((this.ptr)+(4))>>2)] = type;
- };
-
- this.get_type = function() {
- return HEAP32[(((this.ptr)+(4))>>2)];
- };
-
- this.set_destructor = function(destructor) {
- HEAP32[(((this.ptr)+(8))>>2)] = destructor;
- };
-
- this.get_destructor = function() {
- return HEAP32[(((this.ptr)+(8))>>2)];
- };
-
- this.set_refcount = function(refcount) {
- HEAP32[((this.ptr)>>2)] = refcount;
- };
-
- this.set_caught = function (caught) {
- caught = caught ? 1 : 0;
- HEAP8[(((this.ptr)+(12))>>0)] = caught;
- };
-
- this.get_caught = function () {
- return HEAP8[(((this.ptr)+(12))>>0)] != 0;
- };
-
- this.set_rethrown = function (rethrown) {
- rethrown = rethrown ? 1 : 0;
- HEAP8[(((this.ptr)+(13))>>0)] = rethrown;
- };
-
- this.get_rethrown = function () {
- return HEAP8[(((this.ptr)+(13))>>0)] != 0;
- };
-
- // Initialize native structure fields. Should be called once after allocated.
- this.init = function(type, destructor) {
- this.set_type(type);
- this.set_destructor(destructor);
- this.set_refcount(0);
- this.set_caught(false);
- this.set_rethrown(false);
- }
-
- this.add_ref = function() {
- var value = HEAP32[((this.ptr)>>2)];
- HEAP32[((this.ptr)>>2)] = value + 1;
- };
-
- // Returns true if last reference released.
- this.release_ref = function() {
- var prev = HEAP32[((this.ptr)>>2)];
- HEAP32[((this.ptr)>>2)] = prev - 1;
- return prev === 1;
- };
- }
-
- var exceptionLast = 0;
-
- var uncaughtExceptionCount = 0;
- function ___cxa_throw(ptr, type, destructor) {
- var info = new ExceptionInfo(ptr);
- // Initialize ExceptionInfo content after it was allocated in __cxa_allocate_exception.
- info.init(type, destructor);
- exceptionLast = ptr;
- uncaughtExceptionCount++;
- throw ptr;
- }
-
- function _gmtime_r(time, tmPtr) {
- var date = new Date(HEAP32[((time)>>2)]*1000);
- HEAP32[((tmPtr)>>2)] = date.getUTCSeconds();
- HEAP32[(((tmPtr)+(4))>>2)] = date.getUTCMinutes();
- HEAP32[(((tmPtr)+(8))>>2)] = date.getUTCHours();
- HEAP32[(((tmPtr)+(12))>>2)] = date.getUTCDate();
- HEAP32[(((tmPtr)+(16))>>2)] = date.getUTCMonth();
- HEAP32[(((tmPtr)+(20))>>2)] = date.getUTCFullYear()-1900;
- HEAP32[(((tmPtr)+(24))>>2)] = date.getUTCDay();
- HEAP32[(((tmPtr)+(36))>>2)] = 0;
- HEAP32[(((tmPtr)+(32))>>2)] = 0;
- var start = Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0);
- var yday = ((date.getTime() - start) / (1000 * 60 * 60 * 24))|0;
- HEAP32[(((tmPtr)+(28))>>2)] = yday;
- // Allocate a string "GMT" for us to point to.
- if (!_gmtime_r.GMTString) _gmtime_r.GMTString = allocateUTF8("GMT");
- HEAP32[(((tmPtr)+(40))>>2)] = _gmtime_r.GMTString;
- return tmPtr;
- }
- function ___gmtime_r(a0,a1
- ) {
- return _gmtime_r(a0,a1);
- }
-
- function _tzset_impl() {
- var currentYear = new Date().getFullYear();
- var winter = new Date(currentYear, 0, 1);
- var summer = new Date(currentYear, 6, 1);
- var winterOffset = winter.getTimezoneOffset();
- var summerOffset = summer.getTimezoneOffset();
-
- // Local standard timezone offset. Local standard time is not adjusted for daylight savings.
- // This code uses the fact that getTimezoneOffset returns a greater value during Standard Time versus Daylight Saving Time (DST).
- // Thus it determines the expected output during Standard Time, and it compares whether the output of the given date the same (Standard) or less (DST).
- var stdTimezoneOffset = Math.max(winterOffset, summerOffset);
-
- // timezone is specified as seconds west of UTC ("The external variable
- // `timezone` shall be set to the difference, in seconds, between
- // Coordinated Universal Time (UTC) and local standard time."), the same
- // as returned by stdTimezoneOffset.
- // See http://pubs.opengroup.org/onlinepubs/009695399/functions/tzset.html
- HEAP32[((__get_timezone())>>2)] = stdTimezoneOffset * 60;
-
- HEAP32[((__get_daylight())>>2)] = Number(winterOffset != summerOffset);
-
- function extractZone(date) {
- var match = date.toTimeString().match(/\(([A-Za-z ]+)\)$/);
- return match ? match[1] : "GMT";
- };
- var winterName = extractZone(winter);
- var summerName = extractZone(summer);
- var winterNamePtr = allocateUTF8(winterName);
- var summerNamePtr = allocateUTF8(summerName);
- if (summerOffset < winterOffset) {
- // Northern hemisphere
- HEAP32[((__get_tzname())>>2)] = winterNamePtr;
- HEAP32[(((__get_tzname())+(4))>>2)] = summerNamePtr;
- } else {
- HEAP32[((__get_tzname())>>2)] = summerNamePtr;
- HEAP32[(((__get_tzname())+(4))>>2)] = winterNamePtr;
- }
- }
- function _tzset() {
- // TODO: Use (malleable) environment variables instead of system settings.
- if (_tzset.called) return;
- _tzset.called = true;
- _tzset_impl();
- }
- function _localtime_r(time, tmPtr) {
- _tzset();
- var date = new Date(HEAP32[((time)>>2)]*1000);
- HEAP32[((tmPtr)>>2)] = date.getSeconds();
- HEAP32[(((tmPtr)+(4))>>2)] = date.getMinutes();
- HEAP32[(((tmPtr)+(8))>>2)] = date.getHours();
- HEAP32[(((tmPtr)+(12))>>2)] = date.getDate();
- HEAP32[(((tmPtr)+(16))>>2)] = date.getMonth();
- HEAP32[(((tmPtr)+(20))>>2)] = date.getFullYear()-1900;
- HEAP32[(((tmPtr)+(24))>>2)] = date.getDay();
-
- var start = new Date(date.getFullYear(), 0, 1);
- var yday = ((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))|0;
- HEAP32[(((tmPtr)+(28))>>2)] = yday;
- HEAP32[(((tmPtr)+(36))>>2)] = -(date.getTimezoneOffset() * 60);
-
- // Attention: DST is in December in South, and some regions don't have DST at all.
- var summerOffset = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
- var winterOffset = start.getTimezoneOffset();
- var dst = (summerOffset != winterOffset && date.getTimezoneOffset() == Math.min(winterOffset, summerOffset))|0;
- HEAP32[(((tmPtr)+(32))>>2)] = dst;
-
- var zonePtr = HEAP32[(((__get_tzname())+(dst ? 4 : 0))>>2)];
- HEAP32[(((tmPtr)+(40))>>2)] = zonePtr;
-
- return tmPtr;
- }
- function ___localtime_r(a0,a1
- ) {
- return _localtime_r(a0,a1);
- }
-
- var SYSCALLS = {mappings:{},buffers:[null,[],[]],printChar:function(stream, curr) {
- var buffer = SYSCALLS.buffers[stream];
- if (curr === 0 || curr === 10) {
- (stream === 1 ? out : err)(UTF8ArrayToString(buffer, 0));
- buffer.length = 0;
- } else {
- buffer.push(curr);
- }
- },varargs:undefined,get:function() {
- SYSCALLS.varargs += 4;
- var ret = HEAP32[(((SYSCALLS.varargs)-(4))>>2)];
- return ret;
- },getStr:function(ptr) {
- var ret = UTF8ToString(ptr);
- return ret;
- },get64:function(low, high) {
- return low;
- }};
- function ___syscall__newselect(nfds, readfds, writefds, exceptfds, timeout) {
- }
-
- function setErrNo(value) {
- HEAP32[((___errno_location())>>2)] = value;
- return value;
- }
- function ___syscall_fcntl64(fd, cmd, varargs) {SYSCALLS.varargs = varargs;
-
- return 0;
- }
-
- function ___syscall_ioctl(fd, op, varargs) {SYSCALLS.varargs = varargs;
-
- return 0;
- }
-
- function ___syscall_mkdir(path, mode) {
- path = SYSCALLS.getStr(path);
- return SYSCALLS.doMkdir(path, mode);
- }
-
- function ___syscall_open(path, flags, varargs) {SYSCALLS.varargs = varargs;
-
- }
-
- function ___syscall_rmdir(path) {
- }
-
- function ___syscall_unlink(path) {
- }
-
- var structRegistrations = {};
-
- function runDestructors(destructors) {
- while (destructors.length) {
- var ptr = destructors.pop();
- var del = destructors.pop();
- del(ptr);
- }
- }
-
- function simpleReadValueFromPointer(pointer) {
- return this['fromWireType'](HEAPU32[pointer >> 2]);
- }
-
- var awaitingDependencies = {};
-
- var registeredTypes = {};
-
- var typeDependencies = {};
-
- var char_0 = 48;
-
- var char_9 = 57;
- function makeLegalFunctionName(name) {
- if (undefined === name) {
- return '_unknown';
- }
- name = name.replace(/[^a-zA-Z0-9_]/g, '$');
- var f = name.charCodeAt(0);
- if (f >= char_0 && f <= char_9) {
- return '_' + name;
- } else {
- return name;
- }
- }
- function createNamedFunction(name, body) {
- name = makeLegalFunctionName(name);
- /*jshint evil:true*/
- return new Function(
- "body",
- "return function " + name + "() {\n" +
- " \"use strict\";" +
- " return body.apply(this, arguments);\n" +
- "};\n"
- )(body);
- }
- function extendError(baseErrorType, errorName) {
- var errorClass = createNamedFunction(errorName, function(message) {
- this.name = errorName;
- this.message = message;
-
- var stack = (new Error(message)).stack;
- if (stack !== undefined) {
- this.stack = this.toString() + '\n' +
- stack.replace(/^Error(:[^\n]*)?\n/, '');
- }
- });
- errorClass.prototype = Object.create(baseErrorType.prototype);
- errorClass.prototype.constructor = errorClass;
- errorClass.prototype.toString = function() {
- if (this.message === undefined) {
- return this.name;
- } else {
- return this.name + ': ' + this.message;
- }
- };
-
- return errorClass;
- }
- var InternalError = undefined;
- function throwInternalError(message) {
- throw new InternalError(message);
- }
- function whenDependentTypesAreResolved(myTypes, dependentTypes, getTypeConverters) {
- myTypes.forEach(function(type) {
- typeDependencies[type] = dependentTypes;
- });
-
- function onComplete(typeConverters) {
- var myTypeConverters = getTypeConverters(typeConverters);
- if (myTypeConverters.length !== myTypes.length) {
- throwInternalError('Mismatched type converter count');
- }
- for (var i = 0; i < myTypes.length; ++i) {
- registerType(myTypes[i], myTypeConverters[i]);
- }
- }
-
- var typeConverters = new Array(dependentTypes.length);
- var unregisteredTypes = [];
- var registered = 0;
- dependentTypes.forEach(function(dt, i) {
- if (registeredTypes.hasOwnProperty(dt)) {
- typeConverters[i] = registeredTypes[dt];
- } else {
- unregisteredTypes.push(dt);
- if (!awaitingDependencies.hasOwnProperty(dt)) {
- awaitingDependencies[dt] = [];
- }
- awaitingDependencies[dt].push(function() {
- typeConverters[i] = registeredTypes[dt];
- ++registered;
- if (registered === unregisteredTypes.length) {
- onComplete(typeConverters);
- }
- });
- }
- });
- if (0 === unregisteredTypes.length) {
- onComplete(typeConverters);
- }
- }
- function __embind_finalize_value_object(structType) {
- var reg = structRegistrations[structType];
- delete structRegistrations[structType];
-
- var rawConstructor = reg.rawConstructor;
- var rawDestructor = reg.rawDestructor;
- var fieldRecords = reg.fields;
- var fieldTypes = fieldRecords.map(function(field) { return field.getterReturnType; }).
- concat(fieldRecords.map(function(field) { return field.setterArgumentType; }));
- whenDependentTypesAreResolved([structType], fieldTypes, function(fieldTypes) {
- var fields = {};
- fieldRecords.forEach(function(field, i) {
- var fieldName = field.fieldName;
- var getterReturnType = fieldTypes[i];
- var getter = field.getter;
- var getterContext = field.getterContext;
- var setterArgumentType = fieldTypes[i + fieldRecords.length];
- var setter = field.setter;
- var setterContext = field.setterContext;
- fields[fieldName] = {
- read: function(ptr) {
- return getterReturnType['fromWireType'](
- getter(getterContext, ptr));
- },
- write: function(ptr, o) {
- var destructors = [];
- setter(setterContext, ptr, setterArgumentType['toWireType'](destructors, o));
- runDestructors(destructors);
- }
- };
- });
-
- return [{
- name: reg.name,
- 'fromWireType': function(ptr) {
- var rv = {};
- for (var i in fields) {
- rv[i] = fields[i].read(ptr);
- }
- rawDestructor(ptr);
- return rv;
- },
- 'toWireType': function(destructors, o) {
- // todo: Here we have an opportunity for -O3 level "unsafe" optimizations:
- // assume all fields are present without checking.
- for (var fieldName in fields) {
- if (!(fieldName in o)) {
- throw new TypeError('Missing field: "' + fieldName + '"');
- }
- }
- var ptr = rawConstructor();
- for (fieldName in fields) {
- fields[fieldName].write(ptr, o[fieldName]);
- }
- if (destructors !== null) {
- destructors.push(rawDestructor, ptr);
- }
- return ptr;
- },
- 'argPackAdvance': 8,
- 'readValueFromPointer': simpleReadValueFromPointer,
- destructorFunction: rawDestructor,
- }];
- });
- }
-
- function __embind_register_bigint(primitiveType, name, size, minRange, maxRange) {}
-
- function getShiftFromSize(size) {
-
- switch (size) {
- case 1: return 0;
- case 2: return 1;
- case 4: return 2;
- case 8: return 3;
- default:
- throw new TypeError('Unknown type size: ' + size);
- }
- }
-
- function embind_init_charCodes() {
- var codes = new Array(256);
- for (var i = 0; i < 256; ++i) {
- codes[i] = String.fromCharCode(i);
- }
- embind_charCodes = codes;
- }
- var embind_charCodes = undefined;
- function readLatin1String(ptr) {
- var ret = "";
- var c = ptr;
- while (HEAPU8[c]) {
- ret += embind_charCodes[HEAPU8[c++]];
- }
- return ret;
- }
-
- var BindingError = undefined;
- function throwBindingError(message) {
- throw new BindingError(message);
- }
- /** @param {Object=} options */
- function registerType(rawType, registeredInstance, options) {
- options = options || {};
-
- if (!('argPackAdvance' in registeredInstance)) {
- throw new TypeError('registerType registeredInstance requires argPackAdvance');
- }
-
- var name = registeredInstance.name;
- if (!rawType) {
- throwBindingError('type "' + name + '" must have a positive integer typeid pointer');
- }
- if (registeredTypes.hasOwnProperty(rawType)) {
- if (options.ignoreDuplicateRegistrations) {
- return;
- } else {
- throwBindingError("Cannot register type '" + name + "' twice");
- }
- }
-
- registeredTypes[rawType] = registeredInstance;
- delete typeDependencies[rawType];
-
- if (awaitingDependencies.hasOwnProperty(rawType)) {
- var callbacks = awaitingDependencies[rawType];
- delete awaitingDependencies[rawType];
- callbacks.forEach(function(cb) {
- cb();
- });
- }
- }
- function __embind_register_bool(rawType, name, size, trueValue, falseValue) {
- var shift = getShiftFromSize(size);
-
- name = readLatin1String(name);
- registerType(rawType, {
- name: name,
- 'fromWireType': function(wt) {
- // ambiguous emscripten ABI: sometimes return values are
- // true or false, and sometimes integers (0 or 1)
- return !!wt;
- },
- 'toWireType': function(destructors, o) {
- return o ? trueValue : falseValue;
- },
- 'argPackAdvance': 8,
- 'readValueFromPointer': function(pointer) {
- // TODO: if heap is fixed (like in asm.js) this could be executed outside
- var heap;
- if (size === 1) {
- heap = HEAP8;
- } else if (size === 2) {
- heap = HEAP16;
- } else if (size === 4) {
- heap = HEAP32;
- } else {
- throw new TypeError("Unknown boolean type size: " + name);
- }
- return this['fromWireType'](heap[pointer >> shift]);
- },
- destructorFunction: null, // This type does not need a destructor
- });
- }
-
- function ClassHandle_isAliasOf(other) {
- if (!(this instanceof ClassHandle)) {
- return false;
- }
- if (!(other instanceof ClassHandle)) {
- return false;
- }
-
- var leftClass = this.$$.ptrType.registeredClass;
- var left = this.$$.ptr;
- var rightClass = other.$$.ptrType.registeredClass;
- var right = other.$$.ptr;
-
- while (leftClass.baseClass) {
- left = leftClass.upcast(left);
- leftClass = leftClass.baseClass;
- }
-
- while (rightClass.baseClass) {
- right = rightClass.upcast(right);
- rightClass = rightClass.baseClass;
- }
-
- return leftClass === rightClass && left === right;
- }
-
- function shallowCopyInternalPointer(o) {
- return {
- count: o.count,
- deleteScheduled: o.deleteScheduled,
- preservePointerOnDelete: o.preservePointerOnDelete,
- ptr: o.ptr,
- ptrType: o.ptrType,
- smartPtr: o.smartPtr,
- smartPtrType: o.smartPtrType,
- };
- }
-
- function throwInstanceAlreadyDeleted(obj) {
- function getInstanceTypeName(handle) {
- return handle.$$.ptrType.registeredClass.name;
- }
- throwBindingError(getInstanceTypeName(obj) + ' instance already deleted');
- }
-
- var finalizationGroup = false;
-
- function detachFinalizer(handle) {}
-
- function runDestructor($$) {
- if ($$.smartPtr) {
- $$.smartPtrType.rawDestructor($$.smartPtr);
- } else {
- $$.ptrType.registeredClass.rawDestructor($$.ptr);
- }
- }
- function releaseClassHandle($$) {
- $$.count.value -= 1;
- var toDelete = 0 === $$.count.value;
- if (toDelete) {
- runDestructor($$);
- }
- }
- function attachFinalizer(handle) {
- if ('undefined' === typeof FinalizationGroup) {
- attachFinalizer = function (handle) { return handle; };
- return handle;
- }
- // If the running environment has a FinalizationGroup (see
- // https://github.com/tc39/proposal-weakrefs), then attach finalizers
- // for class handles. We check for the presence of FinalizationGroup
- // at run-time, not build-time.
- finalizationGroup = new FinalizationGroup(function (iter) {
- for (var result = iter.next(); !result.done; result = iter.next()) {
- var $$ = result.value;
- if (!$$.ptr) {
- console.warn('object already deleted: ' + $$.ptr);
- } else {
- releaseClassHandle($$);
- }
- }
- });
- attachFinalizer = function(handle) {
- finalizationGroup.register(handle, handle.$$, handle.$$);
- return handle;
- };
- detachFinalizer = function(handle) {
- finalizationGroup.unregister(handle.$$);
- };
- return attachFinalizer(handle);
- }
- function ClassHandle_clone() {
- if (!this.$$.ptr) {
- throwInstanceAlreadyDeleted(this);
- }
-
- if (this.$$.preservePointerOnDelete) {
- this.$$.count.value += 1;
- return this;
- } else {
- var clone = attachFinalizer(Object.create(Object.getPrototypeOf(this), {
- $$: {
- value: shallowCopyInternalPointer(this.$$),
- }
- }));
-
- clone.$$.count.value += 1;
- clone.$$.deleteScheduled = false;
- return clone;
- }
- }
-
- function ClassHandle_delete() {
- if (!this.$$.ptr) {
- throwInstanceAlreadyDeleted(this);
- }
-
- if (this.$$.deleteScheduled && !this.$$.preservePointerOnDelete) {
- throwBindingError('Object already scheduled for deletion');
- }
-
- detachFinalizer(this);
- releaseClassHandle(this.$$);
-
- if (!this.$$.preservePointerOnDelete) {
- this.$$.smartPtr = undefined;
- this.$$.ptr = undefined;
- }
- }
-
- function ClassHandle_isDeleted() {
- return !this.$$.ptr;
- }
-
- var delayFunction = undefined;
-
- var deletionQueue = [];
-
- function flushPendingDeletes() {
- while (deletionQueue.length) {
- var obj = deletionQueue.pop();
- obj.$$.deleteScheduled = false;
- obj['delete']();
- }
- }
- function ClassHandle_deleteLater() {
- if (!this.$$.ptr) {
- throwInstanceAlreadyDeleted(this);
- }
- if (this.$$.deleteScheduled && !this.$$.preservePointerOnDelete) {
- throwBindingError('Object already scheduled for deletion');
- }
- deletionQueue.push(this);
- if (deletionQueue.length === 1 && delayFunction) {
- delayFunction(flushPendingDeletes);
- }
- this.$$.deleteScheduled = true;
- return this;
- }
- function init_ClassHandle() {
- ClassHandle.prototype['isAliasOf'] = ClassHandle_isAliasOf;
- ClassHandle.prototype['clone'] = ClassHandle_clone;
- ClassHandle.prototype['delete'] = ClassHandle_delete;
- ClassHandle.prototype['isDeleted'] = ClassHandle_isDeleted;
- ClassHandle.prototype['deleteLater'] = ClassHandle_deleteLater;
- }
- function ClassHandle() {
- }
-
- var registeredPointers = {};
-
- function ensureOverloadTable(proto, methodName, humanName) {
- if (undefined === proto[methodName].overloadTable) {
- var prevFunc = proto[methodName];
- // Inject an overload resolver function that routes to the appropriate overload based on the number of arguments.
- proto[methodName] = function() {
- // TODO This check can be removed in -O3 level "unsafe" optimizations.
- if (!proto[methodName].overloadTable.hasOwnProperty(arguments.length)) {
- throwBindingError("Function '" + humanName + "' called with an invalid number of arguments (" + arguments.length + ") - expects one of (" + proto[methodName].overloadTable + ")!");
- }
- return proto[methodName].overloadTable[arguments.length].apply(this, arguments);
- };
- // Move the previous function into the overload table.
- proto[methodName].overloadTable = [];
- proto[methodName].overloadTable[prevFunc.argCount] = prevFunc;
- }
- }
- /** @param {number=} numArguments */
- function exposePublicSymbol(name, value, numArguments) {
- if (Module.hasOwnProperty(name)) {
- if (undefined === numArguments || (undefined !== Module[name].overloadTable && undefined !== Module[name].overloadTable[numArguments])) {
- throwBindingError("Cannot register public name '" + name + "' twice");
- }
-
- // We are exposing a function with the same name as an existing function. Create an overload table and a function selector
- // that routes between the two.
- ensureOverloadTable(Module, name, name);
- if (Module.hasOwnProperty(numArguments)) {
- throwBindingError("Cannot register multiple overloads of a function with the same number of arguments (" + numArguments + ")!");
- }
- // Add the new function into the overload table.
- Module[name].overloadTable[numArguments] = value;
- }
- else {
- Module[name] = value;
- if (undefined !== numArguments) {
- Module[name].numArguments = numArguments;
- }
- }
- }
-
- /** @constructor */
- function RegisteredClass(
- name,
- constructor,
- instancePrototype,
- rawDestructor,
- baseClass,
- getActualType,
- upcast,
- downcast
- ) {
- this.name = name;
- this.constructor = constructor;
- this.instancePrototype = instancePrototype;
- this.rawDestructor = rawDestructor;
- this.baseClass = baseClass;
- this.getActualType = getActualType;
- this.upcast = upcast;
- this.downcast = downcast;
- this.pureVirtualFunctions = [];
- }
-
- function upcastPointer(ptr, ptrClass, desiredClass) {
- while (ptrClass !== desiredClass) {
- if (!ptrClass.upcast) {
- throwBindingError("Expected null or instance of " + desiredClass.name + ", got an instance of " + ptrClass.name);
- }
- ptr = ptrClass.upcast(ptr);
- ptrClass = ptrClass.baseClass;
- }
- return ptr;
- }
- function constNoSmartPtrRawPointerToWireType(destructors, handle) {
- if (handle === null) {
- if (this.isReference) {
- throwBindingError('null is not a valid ' + this.name);
- }
- return 0;
- }
-
- if (!handle.$$) {
- throwBindingError('Cannot pass "' + _embind_repr(handle) + '" as a ' + this.name);
- }
- if (!handle.$$.ptr) {
- throwBindingError('Cannot pass deleted object as a pointer of type ' + this.name);
- }
- var handleClass = handle.$$.ptrType.registeredClass;
- var ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass);
- return ptr;
- }
-
- function genericPointerToWireType(destructors, handle) {
- var ptr;
- if (handle === null) {
- if (this.isReference) {
- throwBindingError('null is not a valid ' + this.name);
- }
-
- if (this.isSmartPointer) {
- ptr = this.rawConstructor();
- if (destructors !== null) {
- destructors.push(this.rawDestructor, ptr);
- }
- return ptr;
- } else {
- return 0;
- }
- }
-
- if (!handle.$$) {
- throwBindingError('Cannot pass "' + _embind_repr(handle) + '" as a ' + this.name);
- }
- if (!handle.$$.ptr) {
- throwBindingError('Cannot pass deleted object as a pointer of type ' + this.name);
- }
- if (!this.isConst && handle.$$.ptrType.isConst) {
- throwBindingError('Cannot convert argument of type ' + (handle.$$.smartPtrType ? handle.$$.smartPtrType.name : handle.$$.ptrType.name) + ' to parameter type ' + this.name);
- }
- var handleClass = handle.$$.ptrType.registeredClass;
- ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass);
-
- if (this.isSmartPointer) {
- // TODO: this is not strictly true
- // We could support BY_EMVAL conversions from raw pointers to smart pointers
- // because the smart pointer can hold a reference to the handle
- if (undefined === handle.$$.smartPtr) {
- throwBindingError('Passing raw pointer to smart pointer is illegal');
- }
-
- switch (this.sharingPolicy) {
- case 0: // NONE
- // no upcasting
- if (handle.$$.smartPtrType === this) {
- ptr = handle.$$.smartPtr;
- } else {
- throwBindingError('Cannot convert argument of type ' + (handle.$$.smartPtrType ? handle.$$.smartPtrType.name : handle.$$.ptrType.name) + ' to parameter type ' + this.name);
- }
- break;
-
- case 1: // INTRUSIVE
- ptr = handle.$$.smartPtr;
- break;
-
- case 2: // BY_EMVAL
- if (handle.$$.smartPtrType === this) {
- ptr = handle.$$.smartPtr;
- } else {
- var clonedHandle = handle['clone']();
- ptr = this.rawShare(
- ptr,
- Emval.toHandle(function() {
- clonedHandle['delete']();
- })
- );
- if (destructors !== null) {
- destructors.push(this.rawDestructor, ptr);
- }
- }
- break;
-
- default:
- throwBindingError('Unsupporting sharing policy');
- }
- }
- return ptr;
- }
-
- function nonConstNoSmartPtrRawPointerToWireType(destructors, handle) {
- if (handle === null) {
- if (this.isReference) {
- throwBindingError('null is not a valid ' + this.name);
- }
- return 0;
- }
-
- if (!handle.$$) {
- throwBindingError('Cannot pass "' + _embind_repr(handle) + '" as a ' + this.name);
- }
- if (!handle.$$.ptr) {
- throwBindingError('Cannot pass deleted object as a pointer of type ' + this.name);
- }
- if (handle.$$.ptrType.isConst) {
- throwBindingError('Cannot convert argument of type ' + handle.$$.ptrType.name + ' to parameter type ' + this.name);
- }
- var handleClass = handle.$$.ptrType.registeredClass;
- var ptr = upcastPointer(handle.$$.ptr, handleClass, this.registeredClass);
- return ptr;
- }
-
- function RegisteredPointer_getPointee(ptr) {
- if (this.rawGetPointee) {
- ptr = this.rawGetPointee(ptr);
- }
- return ptr;
- }
-
- function RegisteredPointer_destructor(ptr) {
- if (this.rawDestructor) {
- this.rawDestructor(ptr);
- }
- }
-
- function RegisteredPointer_deleteObject(handle) {
- if (handle !== null) {
- handle['delete']();
- }
- }
-
- function downcastPointer(ptr, ptrClass, desiredClass) {
- if (ptrClass === desiredClass) {
- return ptr;
- }
- if (undefined === desiredClass.baseClass) {
- return null; // no conversion
- }
-
- var rv = downcastPointer(ptr, ptrClass, desiredClass.baseClass);
- if (rv === null) {
- return null;
- }
- return desiredClass.downcast(rv);
- }
-
- function getInheritedInstanceCount() {
- return Object.keys(registeredInstances).length;
- }
-
- function getLiveInheritedInstances() {
- var rv = [];
- for (var k in registeredInstances) {
- if (registeredInstances.hasOwnProperty(k)) {
- rv.push(registeredInstances[k]);
- }
- }
- return rv;
- }
-
- function setDelayFunction(fn) {
- delayFunction = fn;
- if (deletionQueue.length && delayFunction) {
- delayFunction(flushPendingDeletes);
- }
- }
- function init_embind() {
- Module['getInheritedInstanceCount'] = getInheritedInstanceCount;
- Module['getLiveInheritedInstances'] = getLiveInheritedInstances;
- Module['flushPendingDeletes'] = flushPendingDeletes;
- Module['setDelayFunction'] = setDelayFunction;
- }
- var registeredInstances = {};
-
- function getBasestPointer(class_, ptr) {
- if (ptr === undefined) {
- throwBindingError('ptr should not be undefined');
- }
- while (class_.baseClass) {
- ptr = class_.upcast(ptr);
- class_ = class_.baseClass;
- }
- return ptr;
- }
- function getInheritedInstance(class_, ptr) {
- ptr = getBasestPointer(class_, ptr);
- return registeredInstances[ptr];
- }
-
- function makeClassHandle(prototype, record) {
- if (!record.ptrType || !record.ptr) {
- throwInternalError('makeClassHandle requires ptr and ptrType');
- }
- var hasSmartPtrType = !!record.smartPtrType;
- var hasSmartPtr = !!record.smartPtr;
- if (hasSmartPtrType !== hasSmartPtr) {
- throwInternalError('Both smartPtrType and smartPtr must be specified');
- }
- record.count = { value: 1 };
- return attachFinalizer(Object.create(prototype, {
- $$: {
- value: record,
- },
- }));
- }
- function RegisteredPointer_fromWireType(ptr) {
- // ptr is a raw pointer (or a raw smartpointer)
-
- // rawPointer is a maybe-null raw pointer
- var rawPointer = this.getPointee(ptr);
- if (!rawPointer) {
- this.destructor(ptr);
- return null;
- }
-
- var registeredInstance = getInheritedInstance(this.registeredClass, rawPointer);
- if (undefined !== registeredInstance) {
- // JS object has been neutered, time to repopulate it
- if (0 === registeredInstance.$$.count.value) {
- registeredInstance.$$.ptr = rawPointer;
- registeredInstance.$$.smartPtr = ptr;
- return registeredInstance['clone']();
- } else {
- // else, just increment reference count on existing object
- // it already has a reference to the smart pointer
- var rv = registeredInstance['clone']();
- this.destructor(ptr);
- return rv;
- }
- }
-
- function makeDefaultHandle() {
- if (this.isSmartPointer) {
- return makeClassHandle(this.registeredClass.instancePrototype, {
- ptrType: this.pointeeType,
- ptr: rawPointer,
- smartPtrType: this,
- smartPtr: ptr,
- });
- } else {
- return makeClassHandle(this.registeredClass.instancePrototype, {
- ptrType: this,
- ptr: ptr,
- });
- }
- }
-
- var actualType = this.registeredClass.getActualType(rawPointer);
- var registeredPointerRecord = registeredPointers[actualType];
- if (!registeredPointerRecord) {
- return makeDefaultHandle.call(this);
- }
-
- var toType;
- if (this.isConst) {
- toType = registeredPointerRecord.constPointerType;
- } else {
- toType = registeredPointerRecord.pointerType;
- }
- var dp = downcastPointer(
- rawPointer,
- this.registeredClass,
- toType.registeredClass);
- if (dp === null) {
- return makeDefaultHandle.call(this);
- }
- if (this.isSmartPointer) {
- return makeClassHandle(toType.registeredClass.instancePrototype, {
- ptrType: toType,
- ptr: dp,
- smartPtrType: this,
- smartPtr: ptr,
- });
- } else {
- return makeClassHandle(toType.registeredClass.instancePrototype, {
- ptrType: toType,
- ptr: dp,
- });
- }
- }
- function init_RegisteredPointer() {
- RegisteredPointer.prototype.getPointee = RegisteredPointer_getPointee;
- RegisteredPointer.prototype.destructor = RegisteredPointer_destructor;
- RegisteredPointer.prototype['argPackAdvance'] = 8;
- RegisteredPointer.prototype['readValueFromPointer'] = simpleReadValueFromPointer;
- RegisteredPointer.prototype['deleteObject'] = RegisteredPointer_deleteObject;
- RegisteredPointer.prototype['fromWireType'] = RegisteredPointer_fromWireType;
- }
- /** @constructor
- @param {*=} pointeeType,
- @param {*=} sharingPolicy,
- @param {*=} rawGetPointee,
- @param {*=} rawConstructor,
- @param {*=} rawShare,
- @param {*=} rawDestructor,
- */
- function RegisteredPointer(
- name,
- registeredClass,
- isReference,
- isConst,
-
- // smart pointer properties
- isSmartPointer,
- pointeeType,
- sharingPolicy,
- rawGetPointee,
- rawConstructor,
- rawShare,
- rawDestructor
- ) {
- this.name = name;
- this.registeredClass = registeredClass;
- this.isReference = isReference;
- this.isConst = isConst;
-
- // smart pointer properties
- this.isSmartPointer = isSmartPointer;
- this.pointeeType = pointeeType;
- this.sharingPolicy = sharingPolicy;
- this.rawGetPointee = rawGetPointee;
- this.rawConstructor = rawConstructor;
- this.rawShare = rawShare;
- this.rawDestructor = rawDestructor;
-
- if (!isSmartPointer && registeredClass.baseClass === undefined) {
- if (isConst) {
- this['toWireType'] = constNoSmartPtrRawPointerToWireType;
- this.destructorFunction = null;
- } else {
- this['toWireType'] = nonConstNoSmartPtrRawPointerToWireType;
- this.destructorFunction = null;
- }
- } else {
- this['toWireType'] = genericPointerToWireType;
- // Here we must leave this.destructorFunction undefined, since whether genericPointerToWireType returns
- // a pointer that needs to be freed up is runtime-dependent, and cannot be evaluated at registration time.
- // TODO: Create an alternative mechanism that allows removing the use of var destructors = []; array in
- // craftInvokerFunction altogether.
- }
- }
-
- /** @param {number=} numArguments */
- function replacePublicSymbol(name, value, numArguments) {
- if (!Module.hasOwnProperty(name)) {
- throwInternalError('Replacing nonexistant public symbol');
- }
- // If there's an overload table for this symbol, replace the symbol in the overload table instead.
- if (undefined !== Module[name].overloadTable && undefined !== numArguments) {
- Module[name].overloadTable[numArguments] = value;
- }
- else {
- Module[name] = value;
- Module[name].argCount = numArguments;
- }
- }
-
- function dynCallLegacy(sig, ptr, args) {
- var f = Module["dynCall_" + sig];
- return args && args.length ? f.apply(null, [ptr].concat(args)) : f.call(null, ptr);
- }
- function dynCall(sig, ptr, args) {
- return dynCallLegacy(sig, ptr, args);
- }
- function getDynCaller(sig, ptr) {
- var argCache = [];
- return function() {
- argCache.length = arguments.length;
- for (var i = 0; i < arguments.length; i++) {
- argCache[i] = arguments[i];
- }
- return dynCall(sig, ptr, argCache);
- };
- }
- function embind__requireFunction(signature, rawFunction) {
- signature = readLatin1String(signature);
-
- function makeDynCaller() {
- return getDynCaller(signature, rawFunction);
- }
-
- var fp = makeDynCaller();
- if (typeof fp !== "function") {
- throwBindingError("unknown function pointer with signature " + signature + ": " + rawFunction);
- }
- return fp;
- }
-
- var UnboundTypeError = undefined;
-
- function getTypeName(type) {
- var ptr = ___getTypeName(type);
- var rv = readLatin1String(ptr);
- _free(ptr);
- return rv;
- }
- function throwUnboundTypeError(message, types) {
- var unboundTypes = [];
- var seen = {};
- function visit(type) {
- if (seen[type]) {
- return;
- }
- if (registeredTypes[type]) {
- return;
- }
- if (typeDependencies[type]) {
- typeDependencies[type].forEach(visit);
- return;
- }
- unboundTypes.push(type);
- seen[type] = true;
- }
- types.forEach(visit);
-
- throw new UnboundTypeError(message + ': ' + unboundTypes.map(getTypeName).join([', ']));
- }
- function __embind_register_class(
- rawType,
- rawPointerType,
- rawConstPointerType,
- baseClassRawType,
- getActualTypeSignature,
- getActualType,
- upcastSignature,
- upcast,
- downcastSignature,
- downcast,
- name,
- destructorSignature,
- rawDestructor
- ) {
- name = readLatin1String(name);
- getActualType = embind__requireFunction(getActualTypeSignature, getActualType);
- if (upcast) {
- upcast = embind__requireFunction(upcastSignature, upcast);
- }
- if (downcast) {
- downcast = embind__requireFunction(downcastSignature, downcast);
- }
- rawDestructor = embind__requireFunction(destructorSignature, rawDestructor);
- var legalFunctionName = makeLegalFunctionName(name);
-
- exposePublicSymbol(legalFunctionName, function() {
- // this code cannot run if baseClassRawType is zero
- throwUnboundTypeError('Cannot construct ' + name + ' due to unbound types', [baseClassRawType]);
- });
-
- whenDependentTypesAreResolved(
- [rawType, rawPointerType, rawConstPointerType],
- baseClassRawType ? [baseClassRawType] : [],
- function(base) {
- base = base[0];
-
- var baseClass;
- var basePrototype;
- if (baseClassRawType) {
- baseClass = base.registeredClass;
- basePrototype = baseClass.instancePrototype;
- } else {
- basePrototype = ClassHandle.prototype;
- }
-
- var constructor = createNamedFunction(legalFunctionName, function() {
- if (Object.getPrototypeOf(this) !== instancePrototype) {
- throw new BindingError("Use 'new' to construct " + name);
- }
- if (undefined === registeredClass.constructor_body) {
- throw new BindingError(name + " has no accessible constructor");
- }
- var body = registeredClass.constructor_body[arguments.length];
- if (undefined === body) {
- throw new BindingError("Tried to invoke ctor of " + name + " with invalid number of parameters (" + arguments.length + ") - expected (" + Object.keys(registeredClass.constructor_body).toString() + ") parameters instead!");
- }
- return body.apply(this, arguments);
- });
-
- var instancePrototype = Object.create(basePrototype, {
- constructor: { value: constructor },
- });
-
- constructor.prototype = instancePrototype;
-
- var registeredClass = new RegisteredClass(
- name,
- constructor,
- instancePrototype,
- rawDestructor,
- baseClass,
- getActualType,
- upcast,
- downcast);
-
- var referenceConverter = new RegisteredPointer(
- name,
- registeredClass,
- true,
- false,
- false);
-
- var pointerConverter = new RegisteredPointer(
- name + '*',
- registeredClass,
- false,
- false,
- false);
-
- var constPointerConverter = new RegisteredPointer(
- name + ' const*',
- registeredClass,
- false,
- true,
- false);
-
- registeredPointers[rawType] = {
- pointerType: pointerConverter,
- constPointerType: constPointerConverter
- };
-
- replacePublicSymbol(legalFunctionName, constructor);
-
- return [referenceConverter, pointerConverter, constPointerConverter];
- }
- );
- }
-
- function heap32VectorToArray(count, firstElement) {
-
- var array = [];
- for (var i = 0; i < count; i++) {
- array.push(HEAP32[(firstElement >> 2) + i]);
- }
- return array;
- }
- function __embind_register_class_constructor(
- rawClassType,
- argCount,
- rawArgTypesAddr,
- invokerSignature,
- invoker,
- rawConstructor
- ) {
- assert(argCount > 0);
- var rawArgTypes = heap32VectorToArray(argCount, rawArgTypesAddr);
- invoker = embind__requireFunction(invokerSignature, invoker);
- var args = [rawConstructor];
- var destructors = [];
-
- whenDependentTypesAreResolved([], [rawClassType], function(classType) {
- classType = classType[0];
- var humanName = 'constructor ' + classType.name;
-
- if (undefined === classType.registeredClass.constructor_body) {
- classType.registeredClass.constructor_body = [];
- }
- if (undefined !== classType.registeredClass.constructor_body[argCount - 1]) {
- throw new BindingError("Cannot register multiple constructors with identical number of parameters (" + (argCount-1) + ") for class '" + classType.name + "'! Overload resolution is currently only performed using the parameter count, not actual type info!");
- }
- classType.registeredClass.constructor_body[argCount - 1] = function unboundTypeHandler() {
- throwUnboundTypeError('Cannot construct ' + classType.name + ' due to unbound types', rawArgTypes);
- };
-
- whenDependentTypesAreResolved([], rawArgTypes, function(argTypes) {
- // Insert empty slot for context type (argTypes[1]).
- argTypes.splice(1, 0, null);
- classType.registeredClass.constructor_body[argCount - 1] = craftInvokerFunction(humanName, argTypes, null, invoker, rawConstructor);
- return [];
- });
- return [];
- });
- }
-
- function new_(constructor, argumentList) {
- if (!(constructor instanceof Function)) {
- throw new TypeError('new_ called with constructor type ' + typeof(constructor) + " which is not a function");
- }
-
- /*
- * Previously, the following line was just:
-
- function dummy() {};
-
- * Unfortunately, Chrome was preserving 'dummy' as the object's name, even though at creation, the 'dummy' has the
- * correct constructor name. Thus, objects created with IMVU.new would show up in the debugger as 'dummy', which
- * isn't very helpful. Using IMVU.createNamedFunction addresses the issue. Doublely-unfortunately, there's no way
- * to write a test for this behavior. -NRD 2013.02.22
- */
- var dummy = createNamedFunction(constructor.name || 'unknownFunctionName', function(){});
- dummy.prototype = constructor.prototype;
- var obj = new dummy;
-
- var r = constructor.apply(obj, argumentList);
- return (r instanceof Object) ? r : obj;
- }
-
- function runAndAbortIfError(func) {
- try {
- return func();
- } catch (e) {
- abort(e);
- }
- }
-
- function callUserCallback(func, synchronous) {
- if (runtimeExited || ABORT) {
- return;
- }
- // For synchronous calls, let any exceptions propagate, and don't let the runtime exit.
- if (synchronous) {
- func();
- return;
- }
- try {
- func();
- } catch (e) {
- handleException(e);
- }
- }
-
- function runtimeKeepalivePush() {
- runtimeKeepaliveCounter += 1;
- }
-
- function runtimeKeepalivePop() {
- runtimeKeepaliveCounter -= 1;
- }
- var Asyncify = {State:{Normal:0,Unwinding:1,Rewinding:2,Disabled:3},state:0,StackSize:65536,currData:null,handleSleepReturnValue:0,exportCallStack:[],callStackNameToId:{},callStackIdToName:{},callStackId:0,asyncPromiseHandlers:null,sleepCallbacks:[],getCallStackId:function(funcName) {
- var id = Asyncify.callStackNameToId[funcName];
- if (id === undefined) {
- id = Asyncify.callStackId++;
- Asyncify.callStackNameToId[funcName] = id;
- Asyncify.callStackIdToName[id] = funcName;
- }
- return id;
- },instrumentWasmExports:function(exports) {
- var ret = {};
- for (var x in exports) {
- (function(x) {
- var original = exports[x];
- if (typeof original === 'function') {
- ret[x] = function() {
- Asyncify.exportCallStack.push(x);
- try {
- return original.apply(null, arguments);
- } finally {
- if (!ABORT) {
- var y = Asyncify.exportCallStack.pop();
- assert(y === x);
- Asyncify.maybeStopUnwind();
- }
- }
- };
- } else {
- ret[x] = original;
- }
- })(x);
- }
- return ret;
- },maybeStopUnwind:function() {
- if (Asyncify.currData &&
- Asyncify.state === Asyncify.State.Unwinding &&
- Asyncify.exportCallStack.length === 0) {
- // We just finished unwinding.
-
- Asyncify.state = Asyncify.State.Normal;
- // Keep the runtime alive so that a re-wind can be done later.
- runAndAbortIfError(Module['_asyncify_stop_unwind']);
- if (typeof Fibers !== 'undefined') {
- Fibers.trampoline();
- }
- }
- },whenDone:function() {
- return new Promise(function(resolve, reject) {
- Asyncify.asyncPromiseHandlers = {
- resolve: resolve,
- reject: reject
- };
- });
- },allocateData:function() {
- // An asyncify data structure has three fields:
- // 0 current stack pos
- // 4 max stack pos
- // 8 id of function at bottom of the call stack (callStackIdToName[id] == name of js function)
- //
- // The Asyncify ABI only interprets the first two fields, the rest is for the runtime.
- // We also embed a stack in the same memory region here, right next to the structure.
- // This struct is also defined as asyncify_data_t in emscripten/fiber.h
- var ptr = _malloc(12 + Asyncify.StackSize);
- Asyncify.setDataHeader(ptr, ptr + 12, Asyncify.StackSize);
- Asyncify.setDataRewindFunc(ptr);
- return ptr;
- },setDataHeader:function(ptr, stack, stackSize) {
- HEAP32[((ptr)>>2)] = stack;
- HEAP32[(((ptr)+(4))>>2)] = stack + stackSize;
- },setDataRewindFunc:function(ptr) {
- var bottomOfCallStack = Asyncify.exportCallStack[0];
- var rewindId = Asyncify.getCallStackId(bottomOfCallStack);
- HEAP32[(((ptr)+(8))>>2)] = rewindId;
- },getDataRewindFunc:function(ptr) {
- var id = HEAP32[(((ptr)+(8))>>2)];
- var name = Asyncify.callStackIdToName[id];
- var func = Module['asm'][name];
- return func;
- },doRewind:function(ptr) {
- var start = Asyncify.getDataRewindFunc(ptr);
- // Once we have rewound and the stack we no longer need to artificially keep
- // the runtime alive.
-
- return start();
- },handleSleep:function(startAsync) {
- if (ABORT) return;
- if (Asyncify.state === Asyncify.State.Normal) {
- // Prepare to sleep. Call startAsync, and see what happens:
- // if the code decided to call our callback synchronously,
- // then no async operation was in fact begun, and we don't
- // need to do anything.
- var reachedCallback = false;
- var reachedAfterCallback = false;
- startAsync(function(handleSleepReturnValue) {
- if (ABORT) return;
- Asyncify.handleSleepReturnValue = handleSleepReturnValue || 0;
- reachedCallback = true;
- if (!reachedAfterCallback) {
- // We are happening synchronously, so no need for async.
- return;
- }
- Asyncify.state = Asyncify.State.Rewinding;
- runAndAbortIfError(function() { Module['_asyncify_start_rewind'](Asyncify.currData) });
- if (typeof Browser !== 'undefined' && Browser.mainLoop.func) {
- Browser.mainLoop.resume();
- }
- var asyncWasmReturnValue, isError = false;
- try {
- asyncWasmReturnValue = Asyncify.doRewind(Asyncify.currData);
- } catch (err) {
- asyncWasmReturnValue = err;
- isError = true;
- }
- // Track whether the return value was handled by any promise handlers.
- var handled = false;
- if (!Asyncify.currData) {
- // All asynchronous execution has finished.
- // `asyncWasmReturnValue` now contains the final
- // return value of the exported async WASM function.
- //
- // Note: `asyncWasmReturnValue` is distinct from
- // `Asyncify.handleSleepReturnValue`.
- // `Asyncify.handleSleepReturnValue` contains the return
- // value of the last C function to have executed
- // `Asyncify.handleSleep()`, where as `asyncWasmReturnValue`
- // contains the return value of the exported WASM function
- // that may have called C functions that
- // call `Asyncify.handleSleep()`.
- var asyncPromiseHandlers = Asyncify.asyncPromiseHandlers;
- if (asyncPromiseHandlers) {
- Asyncify.asyncPromiseHandlers = null;
- (isError ? asyncPromiseHandlers.reject : asyncPromiseHandlers.resolve)(asyncWasmReturnValue);
- handled = true;
- }
- }
- if (isError && !handled) {
- // If there was an error and it was not handled by now, we have no choice but to
- // rethrow that error into the global scope where it can be caught only by
- // `onerror` or `onunhandledpromiserejection`.
- throw asyncWasmReturnValue;
- }
- });
- reachedAfterCallback = true;
- if (!reachedCallback) {
- // A true async operation was begun; start a sleep.
- Asyncify.state = Asyncify.State.Unwinding;
- // TODO: reuse, don't alloc/free every sleep
- Asyncify.currData = Asyncify.allocateData();
- runAndAbortIfError(function() { Module['_asyncify_start_unwind'](Asyncify.currData) });
- if (typeof Browser !== 'undefined' && Browser.mainLoop.func) {
- Browser.mainLoop.pause();
- }
- }
- } else if (Asyncify.state === Asyncify.State.Rewinding) {
- // Stop a resume.
- Asyncify.state = Asyncify.State.Normal;
- runAndAbortIfError(Module['_asyncify_stop_rewind']);
- _free(Asyncify.currData);
- Asyncify.currData = null;
- // Call all sleep callbacks now that the sleep-resume is all done.
- Asyncify.sleepCallbacks.forEach(function(func) {
- callUserCallback(func);
- });
- } else {
- abort('invalid state: ' + Asyncify.state);
- }
- return Asyncify.handleSleepReturnValue;
- },handleAsync:function(startAsync) {
- return Asyncify.handleSleep(function(wakeUp) {
- // TODO: add error handling as a second param when handleSleep implements it.
- startAsync().then(wakeUp);
- });
- }};
- function craftInvokerFunction(humanName, argTypes, classType, cppInvokerFunc, cppTargetFunc) {
- // humanName: a human-readable string name for the function to be generated.
- // argTypes: An array that contains the embind type objects for all types in the function signature.
- // argTypes[0] is the type object for the function return value.
- // argTypes[1] is the type object for function this object/class type, or null if not crafting an invoker for a class method.
- // argTypes[2...] are the actual function parameters.
- // classType: The embind type object for the class to be bound, or null if this is not a method of a class.
- // cppInvokerFunc: JS Function object to the C++-side function that interops into C++ code.
- // cppTargetFunc: Function pointer (an integer to FUNCTION_TABLE) to the target C++ function the cppInvokerFunc will end up calling.
- var argCount = argTypes.length;
-
- if (argCount < 2) {
- throwBindingError("argTypes array size mismatch! Must at least get return value and 'this' types!");
- }
-
- var isClassMethodFunc = (argTypes[1] !== null && classType !== null);
-
- // Free functions with signature "void function()" do not need an invoker that marshalls between wire types.
- // TODO: This omits argument count check - enable only at -O3 or similar.
- // if (ENABLE_UNSAFE_OPTS && argCount == 2 && argTypes[0].name == "void" && !isClassMethodFunc) {
- // return FUNCTION_TABLE[fn];
- // }
-
- // Determine if we need to use a dynamic stack to store the destructors for the function parameters.
- // TODO: Remove this completely once all function invokers are being dynamically generated.
- var needsDestructorStack = false;
-
- for (var i = 1; i < argTypes.length; ++i) { // Skip return value at index 0 - it's not deleted here.
- if (argTypes[i] !== null && argTypes[i].destructorFunction === undefined) { // The type does not define a destructor function - must use dynamic stack
- needsDestructorStack = true;
- break;
- }
- }
-
- var returns = (argTypes[0].name !== "void");
-
- var argsList = "";
- var argsListWired = "";
- for (var i = 0; i < argCount - 2; ++i) {
- argsList += (i!==0?", ":"")+"arg"+i;
- argsListWired += (i!==0?", ":"")+"arg"+i+"Wired";
- }
-
- var invokerFnBody =
- "return function "+makeLegalFunctionName(humanName)+"("+argsList+") {\n" +
- "if (arguments.length !== "+(argCount - 2)+") {\n" +
- "throwBindingError('function "+humanName+" called with ' + arguments.length + ' arguments, expected "+(argCount - 2)+" args!');\n" +
- "}\n";
-
- if (needsDestructorStack) {
- invokerFnBody +=
- "var destructors = [];\n";
- }
-
- var dtorStack = needsDestructorStack ? "destructors" : "null";
- var args1 = ["throwBindingError", "invoker", "fn", "runDestructors", "retType", "classParam"];
- var args2 = [throwBindingError, cppInvokerFunc, cppTargetFunc, runDestructors, argTypes[0], argTypes[1]];
-
- if (isClassMethodFunc) {
- invokerFnBody += "var thisWired = classParam.toWireType("+dtorStack+", this);\n";
- }
-
- for (var i = 0; i < argCount - 2; ++i) {
- invokerFnBody += "var arg"+i+"Wired = argType"+i+".toWireType("+dtorStack+", arg"+i+"); // "+argTypes[i+2].name+"\n";
- args1.push("argType"+i);
- args2.push(argTypes[i+2]);
- }
-
- if (isClassMethodFunc) {
- argsListWired = "thisWired" + (argsListWired.length > 0 ? ", " : "") + argsListWired;
- }
-
- invokerFnBody +=
- (returns?"var rv = ":"") + "invoker(fn"+(argsListWired.length>0?", ":"")+argsListWired+");\n";
-
- args1.push("Asyncify");
- args2.push(Asyncify);
- invokerFnBody += "function onDone(" + (returns ? "rv" : "") + ") {\n";
-
- if (needsDestructorStack) {
- invokerFnBody += "runDestructors(destructors);\n";
- } else {
- for (var i = isClassMethodFunc?1:2; i < argTypes.length; ++i) { // Skip return value at index 0 - it's not deleted here. Also skip class type if not a method.
- var paramName = (i === 1 ? "thisWired" : ("arg"+(i - 2)+"Wired"));
- if (argTypes[i].destructorFunction !== null) {
- invokerFnBody += paramName+"_dtor("+paramName+"); // "+argTypes[i].name+"\n";
- args1.push(paramName+"_dtor");
- args2.push(argTypes[i].destructorFunction);
- }
- }
- }
-
- if (returns) {
- invokerFnBody += "var ret = retType.fromWireType(rv);\n" +
- "return ret;\n";
- } else {
- }
-
- invokerFnBody += "}\n";
- invokerFnBody += "return Asyncify.currData ? Asyncify.whenDone().then(onDone) : onDone(" + (returns ? "rv" : "") +");\n"
-
- invokerFnBody += "}\n";
-
- args1.push(invokerFnBody);
-
- var invokerFunction = new_(Function, args1).apply(null, args2);
- return invokerFunction;
- }
- function __embind_register_class_function(
- rawClassType,
- methodName,
- argCount,
- rawArgTypesAddr, // [ReturnType, ThisType, Args...]
- invokerSignature,
- rawInvoker,
- context,
- isPureVirtual
- ) {
- var rawArgTypes = heap32VectorToArray(argCount, rawArgTypesAddr);
- methodName = readLatin1String(methodName);
- rawInvoker = embind__requireFunction(invokerSignature, rawInvoker);
-
- whenDependentTypesAreResolved([], [rawClassType], function(classType) {
- classType = classType[0];
- var humanName = classType.name + '.' + methodName;
-
- if (methodName.startsWith("@@")) {
- methodName = Symbol[methodName.substring(2)];
- }
-
- if (isPureVirtual) {
- classType.registeredClass.pureVirtualFunctions.push(methodName);
- }
-
- function unboundTypesHandler() {
- throwUnboundTypeError('Cannot call ' + humanName + ' due to unbound types', rawArgTypes);
- }
-
- var proto = classType.registeredClass.instancePrototype;
- var method = proto[methodName];
- if (undefined === method || (undefined === method.overloadTable && method.className !== classType.name && method.argCount === argCount - 2)) {
- // This is the first overload to be registered, OR we are replacing a function in the base class with a function in the derived class.
- unboundTypesHandler.argCount = argCount - 2;
- unboundTypesHandler.className = classType.name;
- proto[methodName] = unboundTypesHandler;
- } else {
- // There was an existing function with the same name registered. Set up a function overload routing table.
- ensureOverloadTable(proto, methodName, humanName);
- proto[methodName].overloadTable[argCount - 2] = unboundTypesHandler;
- }
-
- whenDependentTypesAreResolved([], rawArgTypes, function(argTypes) {
-
- var memberFunction = craftInvokerFunction(humanName, argTypes, classType, rawInvoker, context);
-
- // Replace the initial unbound-handler-stub function with the appropriate member function, now that all types
- // are resolved. If multiple overloads are registered for this function, the function goes into an overload table.
- if (undefined === proto[methodName].overloadTable) {
- // Set argCount in case an overload is registered later
- memberFunction.argCount = argCount - 2;
- proto[methodName] = memberFunction;
- } else {
- proto[methodName].overloadTable[argCount - 2] = memberFunction;
- }
-
- return [];
- });
- return [];
- });
- }
-
- var emval_free_list = [];
-
- var emval_handle_array = [{},{value:undefined},{value:null},{value:true},{value:false}];
- function __emval_decref(handle) {
- if (handle > 4 && 0 === --emval_handle_array[handle].refcount) {
- emval_handle_array[handle] = undefined;
- emval_free_list.push(handle);
- }
- }
-
- function count_emval_handles() {
- var count = 0;
- for (var i = 5; i < emval_handle_array.length; ++i) {
- if (emval_handle_array[i] !== undefined) {
- ++count;
- }
- }
- return count;
- }
-
- function get_first_emval() {
- for (var i = 5; i < emval_handle_array.length; ++i) {
- if (emval_handle_array[i] !== undefined) {
- return emval_handle_array[i];
- }
- }
- return null;
- }
- function init_emval() {
- Module['count_emval_handles'] = count_emval_handles;
- Module['get_first_emval'] = get_first_emval;
- }
- var Emval = {toValue:function(handle) {
- if (!handle) {
- throwBindingError('Cannot use deleted val. handle = ' + handle);
- }
- return emval_handle_array[handle].value;
- },toHandle:function(value) {
- switch (value) {
- case undefined :{ return 1; }
- case null :{ return 2; }
- case true :{ return 3; }
- case false :{ return 4; }
- default:{
- var handle = emval_free_list.length ?
- emval_free_list.pop() :
- emval_handle_array.length;
-
- emval_handle_array[handle] = {refcount: 1, value: value};
- return handle;
- }
- }
- }};
- function __embind_register_emval(rawType, name) {
- name = readLatin1String(name);
- registerType(rawType, {
- name: name,
- 'fromWireType': function(handle) {
- var rv = Emval.toValue(handle);
- __emval_decref(handle);
- return rv;
- },
- 'toWireType': function(destructors, value) {
- return Emval.toHandle(value);
- },
- 'argPackAdvance': 8,
- 'readValueFromPointer': simpleReadValueFromPointer,
- destructorFunction: null, // This type does not need a destructor
-
- // TODO: do we need a deleteObject here? write a test where
- // emval is passed into JS via an interface
- });
- }
-
- function _embind_repr(v) {
- if (v === null) {
- return 'null';
- }
- var t = typeof v;
- if (t === 'object' || t === 'array' || t === 'function') {
- return v.toString();
- } else {
- return '' + v;
- }
- }
-
- function floatReadValueFromPointer(name, shift) {
- switch (shift) {
- case 2: return function(pointer) {
- return this['fromWireType'](HEAPF32[pointer >> 2]);
- };
- case 3: return function(pointer) {
- return this['fromWireType'](HEAPF64[pointer >> 3]);
- };
- default:
- throw new TypeError("Unknown float type: " + name);
- }
- }
- function __embind_register_float(rawType, name, size) {
- var shift = getShiftFromSize(size);
- name = readLatin1String(name);
- registerType(rawType, {
- name: name,
- 'fromWireType': function(value) {
- return value;
- },
- 'toWireType': function(destructors, value) {
- // The VM will perform JS to Wasm value conversion, according to the spec:
- // https://www.w3.org/TR/wasm-js-api-1/#towebassemblyvalue
- return value;
- },
- 'argPackAdvance': 8,
- 'readValueFromPointer': floatReadValueFromPointer(name, shift),
- destructorFunction: null, // This type does not need a destructor
- });
- }
-
- function __embind_register_function(name, argCount, rawArgTypesAddr, signature, rawInvoker, fn) {
- var argTypes = heap32VectorToArray(argCount, rawArgTypesAddr);
- name = readLatin1String(name);
-
- rawInvoker = embind__requireFunction(signature, rawInvoker);
-
- exposePublicSymbol(name, function() {
- throwUnboundTypeError('Cannot call ' + name + ' due to unbound types', argTypes);
- }, argCount - 1);
-
- whenDependentTypesAreResolved([], argTypes, function(argTypes) {
- var invokerArgsArray = [argTypes[0] /* return value */, null /* no class 'this'*/].concat(argTypes.slice(1) /* actual params */);
- replacePublicSymbol(name, craftInvokerFunction(name, invokerArgsArray, null /* no class 'this'*/, rawInvoker, fn), argCount - 1);
- return [];
- });
- }
-
- function integerReadValueFromPointer(name, shift, signed) {
- // integers are quite common, so generate very specialized functions
- switch (shift) {
- case 0: return signed ?
- function readS8FromPointer(pointer) { return HEAP8[pointer]; } :
- function readU8FromPointer(pointer) { return HEAPU8[pointer]; };
- case 1: return signed ?
- function readS16FromPointer(pointer) { return HEAP16[pointer >> 1]; } :
- function readU16FromPointer(pointer) { return HEAPU16[pointer >> 1]; };
- case 2: return signed ?
- function readS32FromPointer(pointer) { return HEAP32[pointer >> 2]; } :
- function readU32FromPointer(pointer) { return HEAPU32[pointer >> 2]; };
- default:
- throw new TypeError("Unknown integer type: " + name);
- }
- }
- function __embind_register_integer(primitiveType, name, size, minRange, maxRange) {
- name = readLatin1String(name);
- if (maxRange === -1) { // LLVM doesn't have signed and unsigned 32-bit types, so u32 literals come out as 'i32 -1'. Always treat those as max u32.
- maxRange = 4294967295;
- }
-
- var shift = getShiftFromSize(size);
-
- var fromWireType = function(value) {
- return value;
- };
-
- if (minRange === 0) {
- var bitshift = 32 - 8*size;
- fromWireType = function(value) {
- return (value << bitshift) >>> bitshift;
- };
- }
-
- var isUnsignedType = (name.includes('unsigned'));
-
- registerType(primitiveType, {
- name: name,
- 'fromWireType': fromWireType,
- 'toWireType': function(destructors, value) {
- // todo: Here we have an opportunity for -O3 level "unsafe" optimizations: we could
- // avoid the following two if()s and assume value is of proper type.
- if (typeof value !== "number" && typeof value !== "boolean") {
- throw new TypeError('Cannot convert "' + _embind_repr(value) + '" to ' + this.name);
- }
- if (value < minRange || value > maxRange) {
- throw new TypeError('Passing a number "' + _embind_repr(value) + '" from JS side to C/C++ side to an argument of type "' + name + '", which is outside the valid range [' + minRange + ', ' + maxRange + ']!');
- }
- return isUnsignedType ? (value >>> 0) : (value | 0);
- },
- 'argPackAdvance': 8,
- 'readValueFromPointer': integerReadValueFromPointer(name, shift, minRange !== 0),
- destructorFunction: null, // This type does not need a destructor
- });
- }
-
- function __embind_register_memory_view(rawType, dataTypeIndex, name) {
- var typeMapping = [
- Int8Array,
- Uint8Array,
- Int16Array,
- Uint16Array,
- Int32Array,
- Uint32Array,
- Float32Array,
- Float64Array,
- ];
-
- var TA = typeMapping[dataTypeIndex];
-
- function decodeMemoryView(handle) {
- handle = handle >> 2;
- var heap = HEAPU32;
- var size = heap[handle]; // in elements
- var data = heap[handle + 1]; // byte offset into emscripten heap
- return new TA(buffer, data, size);
- }
-
- name = readLatin1String(name);
- registerType(rawType, {
- name: name,
- 'fromWireType': decodeMemoryView,
- 'argPackAdvance': 8,
- 'readValueFromPointer': decodeMemoryView,
- }, {
- ignoreDuplicateRegistrations: true,
- });
- }
-
- function __embind_register_std_string(rawType, name) {
- name = readLatin1String(name);
- var stdStringIsUTF8
- //process only std::string bindings with UTF8 support, in contrast to e.g. std::basic_string
- = (name === "std::string");
-
- registerType(rawType, {
- name: name,
- 'fromWireType': function(value) {
- var length = HEAPU32[value >> 2];
-
- var str;
- if (stdStringIsUTF8) {
- var decodeStartPtr = value + 4;
- // Looping here to support possible embedded '0' bytes
- for (var i = 0; i <= length; ++i) {
- var currentBytePtr = value + 4 + i;
- if (i == length || HEAPU8[currentBytePtr] == 0) {
- var maxRead = currentBytePtr - decodeStartPtr;
- var stringSegment = UTF8ToString(decodeStartPtr, maxRead);
- if (str === undefined) {
- str = stringSegment;
- } else {
- str += String.fromCharCode(0);
- str += stringSegment;
- }
- decodeStartPtr = currentBytePtr + 1;
- }
- }
- } else {
- var a = new Array(length);
- for (var i = 0; i < length; ++i) {
- a[i] = String.fromCharCode(HEAPU8[value + 4 + i]);
- }
- str = a.join('');
- }
-
- _free(value);
-
- return str;
- },
- 'toWireType': function(destructors, value) {
- if (value instanceof ArrayBuffer) {
- value = new Uint8Array(value);
- }
-
- var getLength;
- var valueIsOfTypeString = (typeof value === 'string');
-
- if (!(valueIsOfTypeString || value instanceof Uint8Array || value instanceof Uint8ClampedArray || value instanceof Int8Array)) {
- throwBindingError('Cannot pass non-string to std::string');
- }
- if (stdStringIsUTF8 && valueIsOfTypeString) {
- getLength = function() {return lengthBytesUTF8(value);};
- } else {
- getLength = function() {return value.length;};
- }
-
- // assumes 4-byte alignment
- var length = getLength();
- var ptr = _malloc(4 + length + 1);
- HEAPU32[ptr >> 2] = length;
- if (stdStringIsUTF8 && valueIsOfTypeString) {
- stringToUTF8(value, ptr + 4, length + 1);
- } else {
- if (valueIsOfTypeString) {
- for (var i = 0; i < length; ++i) {
- var charCode = value.charCodeAt(i);
- if (charCode > 255) {
- _free(ptr);
- throwBindingError('String has UTF-16 code units that do not fit in 8 bits');
- }
- HEAPU8[ptr + 4 + i] = charCode;
- }
- } else {
- for (var i = 0; i < length; ++i) {
- HEAPU8[ptr + 4 + i] = value[i];
- }
- }
- }
-
- if (destructors !== null) {
- destructors.push(_free, ptr);
- }
- return ptr;
- },
- 'argPackAdvance': 8,
- 'readValueFromPointer': simpleReadValueFromPointer,
- destructorFunction: function(ptr) { _free(ptr); },
- });
- }
-
- function __embind_register_std_wstring(rawType, charSize, name) {
- name = readLatin1String(name);
- var decodeString, encodeString, getHeap, lengthBytesUTF, shift;
- if (charSize === 2) {
- decodeString = UTF16ToString;
- encodeString = stringToUTF16;
- lengthBytesUTF = lengthBytesUTF16;
- getHeap = function() { return HEAPU16; };
- shift = 1;
- } else if (charSize === 4) {
- decodeString = UTF32ToString;
- encodeString = stringToUTF32;
- lengthBytesUTF = lengthBytesUTF32;
- getHeap = function() { return HEAPU32; };
- shift = 2;
- }
- registerType(rawType, {
- name: name,
- 'fromWireType': function(value) {
- // Code mostly taken from _embind_register_std_string fromWireType
- var length = HEAPU32[value >> 2];
- var HEAP = getHeap();
- var str;
-
- var decodeStartPtr = value + 4;
- // Looping here to support possible embedded '0' bytes
- for (var i = 0; i <= length; ++i) {
- var currentBytePtr = value + 4 + i * charSize;
- if (i == length || HEAP[currentBytePtr >> shift] == 0) {
- var maxReadBytes = currentBytePtr - decodeStartPtr;
- var stringSegment = decodeString(decodeStartPtr, maxReadBytes);
- if (str === undefined) {
- str = stringSegment;
- } else {
- str += String.fromCharCode(0);
- str += stringSegment;
- }
- decodeStartPtr = currentBytePtr + charSize;
- }
- }
-
- _free(value);
-
- return str;
- },
- 'toWireType': function(destructors, value) {
- if (!(typeof value === 'string')) {
- throwBindingError('Cannot pass non-string to C++ string type ' + name);
- }
-
- // assumes 4-byte alignment
- var length = lengthBytesUTF(value);
- var ptr = _malloc(4 + length + charSize);
- HEAPU32[ptr >> 2] = length >> shift;
-
- encodeString(value, ptr + 4, length + charSize);
-
- if (destructors !== null) {
- destructors.push(_free, ptr);
- }
- return ptr;
- },
- 'argPackAdvance': 8,
- 'readValueFromPointer': simpleReadValueFromPointer,
- destructorFunction: function(ptr) { _free(ptr); },
- });
- }
-
- function __embind_register_value_object(
- rawType,
- name,
- constructorSignature,
- rawConstructor,
- destructorSignature,
- rawDestructor
- ) {
- structRegistrations[rawType] = {
- name: readLatin1String(name),
- rawConstructor: embind__requireFunction(constructorSignature, rawConstructor),
- rawDestructor: embind__requireFunction(destructorSignature, rawDestructor),
- fields: [],
- };
- }
-
- function __embind_register_value_object_field(
- structType,
- fieldName,
- getterReturnType,
- getterSignature,
- getter,
- getterContext,
- setterArgumentType,
- setterSignature,
- setter,
- setterContext
- ) {
- structRegistrations[structType].fields.push({
- fieldName: readLatin1String(fieldName),
- getterReturnType: getterReturnType,
- getter: embind__requireFunction(getterSignature, getter),
- getterContext: getterContext,
- setterArgumentType: setterArgumentType,
- setter: embind__requireFunction(setterSignature, setter),
- setterContext: setterContext,
- });
- }
-
- function __embind_register_void(rawType, name) {
- name = readLatin1String(name);
- registerType(rawType, {
- isVoid: true, // void return values can be optimized out sometimes
- name: name,
- 'argPackAdvance': 0,
- 'fromWireType': function() {
- return undefined;
- },
- 'toWireType': function(destructors, o) {
- // TODO: assert if anything else is given?
- return undefined;
- },
- });
- }
-
- function _abort() {
- abort('');
- }
-
- function _clock() {
- if (_clock.start === undefined) _clock.start = Date.now();
- return ((Date.now() - _clock.start) * (1000000 / 1000))|0;
- }
-
- var _emscripten_get_now;if (typeof performance !== 'undefined' && performance.now) {
- _emscripten_get_now = function() { return performance.now(); }
- } else {
- _emscripten_get_now = Date.now;
- }
-
- var _emscripten_get_now_is_monotonic =
- ((typeof performance === 'object' && performance && typeof performance['now'] === 'function')
- );;
- function _clock_gettime(clk_id, tp) {
- // int clock_gettime(clockid_t clk_id, struct timespec *tp);
- var now;
- if (clk_id === 0) {
- now = Date.now();
- } else if ((clk_id === 1 || clk_id === 4) && _emscripten_get_now_is_monotonic) {
- now = _emscripten_get_now();
- } else {
- setErrNo(28);
- return -1;
- }
- HEAP32[((tp)>>2)] = (now/1000)|0; // seconds
- HEAP32[(((tp)+(4))>>2)] = ((now % 1000)*1000*1000)|0; // nanoseconds
- return 0;
- }
-
- var readAsmConstArgsArray = [];
- function readAsmConstArgs(sigPtr, buf) {
- ;
- readAsmConstArgsArray.length = 0;
- var ch;
- // Most arguments are i32s, so shift the buffer pointer so it is a plain
- // index into HEAP32.
- buf >>= 2;
- while (ch = HEAPU8[sigPtr++]) {
- // A double takes two 32-bit slots, and must also be aligned - the backend
- // will emit padding to avoid that.
- var readAsmConstArgsDouble = ch < 105;
- if (readAsmConstArgsDouble && (buf & 1)) buf++;
- readAsmConstArgsArray.push(readAsmConstArgsDouble ? HEAPF64[buf++ >> 1] : HEAP32[buf]);
- ++buf;
- }
- return readAsmConstArgsArray;
- }
- function _emscripten_asm_const_int(code, sigPtr, argbuf) {
- var args = readAsmConstArgs(sigPtr, argbuf);
- return ASM_CONSTS[code].apply(null, args);
- }
-
-
- var _emscripten_memcpy_big = Uint8Array.prototype.copyWithin
- ? function(dest, src, num) { HEAPU8.copyWithin(dest, src, src + num); }
- : function(dest, src, num) { HEAPU8.set(HEAPU8.subarray(src, src+num), dest); }
- ;
-
- function emscripten_realloc_buffer(size) {
- try {
- // round size grow request up to wasm page size (fixed 64KB per spec)
- wasmMemory.grow((size - buffer.byteLength + 65535) >>> 16); // .grow() takes a delta compared to the previous size
- updateGlobalBufferAndViews(wasmMemory.buffer);
- return 1 /*success*/;
- } catch(e) {
- }
- // implicit 0 return to save code size (caller will cast "undefined" into 0
- // anyhow)
- }
- function _emscripten_resize_heap(requestedSize) {
- var oldSize = HEAPU8.length;
- requestedSize = requestedSize >>> 0;
- // With pthreads, races can happen (another thread might increase the size in between), so return a failure, and let the caller retry.
-
- // Memory resize rules:
- // 1. Always increase heap size to at least the requested size, rounded up to next page multiple.
- // 2a. If MEMORY_GROWTH_LINEAR_STEP == -1, excessively resize the heap geometrically: increase the heap size according to
- // MEMORY_GROWTH_GEOMETRIC_STEP factor (default +20%),
- // At most overreserve by MEMORY_GROWTH_GEOMETRIC_CAP bytes (default 96MB).
- // 2b. If MEMORY_GROWTH_LINEAR_STEP != -1, excessively resize the heap linearly: increase the heap size by at least MEMORY_GROWTH_LINEAR_STEP bytes.
- // 3. Max size for the heap is capped at 2048MB-WASM_PAGE_SIZE, or by MAXIMUM_MEMORY, or by ASAN limit, depending on which is smallest
- // 4. If we were unable to allocate as much memory, it may be due to over-eager decision to excessively reserve due to (3) above.
- // Hence if an allocation fails, cut down on the amount of excess growth, in an attempt to succeed to perform a smaller allocation.
-
- // A limit is set for how much we can grow. We should not exceed that
- // (the wasm binary specifies it, so if we tried, we'd fail anyhow).
- // In CAN_ADDRESS_2GB mode, stay one Wasm page short of 4GB: while e.g. Chrome is able to allocate full 4GB Wasm memories, the size will wrap
- // back to 0 bytes in Wasm side for any code that deals with heap sizes, which would require special casing all heap size related code to treat
- // 0 specially.
- var maxHeapSize = MAX_HEAP_SIZE;
- if (requestedSize > maxHeapSize) {
- return false;
- }
-
- // Loop through potential heap size increases. If we attempt a too eager reservation that fails, cut down on the
- // attempted size and reserve a smaller bump instead. (max 3 times, chosen somewhat arbitrarily)
- for (var cutDown = 1; cutDown <= 4; cutDown *= 2) {
- var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); // ensure geometric growth
- // but limit overreserving (default to capping at +96MB overgrowth at most)
- overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296 );
-
- var newSize = Math.min(maxHeapSize, alignUp(Math.max(requestedSize, overGrownHeapSize), 65536));
-
- var replacement = emscripten_realloc_buffer(newSize);
- if (replacement) {
-
- return true;
- }
- }
- return false;
- }
-
- var ENV = {};
-
- function getExecutableName() {
- return thisProgram || './this.program';
- }
- function getEnvStrings() {
- if (!getEnvStrings.strings) {
- // Default values.
- // Browser language detection #8751
- var lang = ((typeof navigator === 'object' && navigator.languages && navigator.languages[0]) || 'C').replace('-', '_') + '.UTF-8';
- var env = {
- 'USER': 'web_user',
- 'LOGNAME': 'web_user',
- 'PATH': '/',
- 'PWD': '/',
- 'HOME': '/home/web_user',
- 'LANG': lang,
- '_': getExecutableName()
- };
- // Apply the user-provided values, if any.
- for (var x in ENV) {
- // x is a key in ENV; if ENV[x] is undefined, that means it was
- // explicitly set to be so. We allow user code to do that to
- // force variables with default values to remain unset.
- if (ENV[x] === undefined) delete env[x];
- else env[x] = ENV[x];
- }
- var strings = [];
- for (var x in env) {
- strings.push(x + '=' + env[x]);
- }
- getEnvStrings.strings = strings;
- }
- return getEnvStrings.strings;
- }
- function _environ_get(__environ, environ_buf) {
- var bufSize = 0;
- getEnvStrings().forEach(function(string, i) {
- var ptr = environ_buf + bufSize;
- HEAP32[(((__environ)+(i * 4))>>2)] = ptr;
- writeAsciiToMemory(string, ptr);
- bufSize += string.length + 1;
- });
- return 0;
- }
-
- function _environ_sizes_get(penviron_count, penviron_buf_size) {
- var strings = getEnvStrings();
- HEAP32[((penviron_count)>>2)] = strings.length;
- var bufSize = 0;
- strings.forEach(function(string) {
- bufSize += string.length + 1;
- });
- HEAP32[((penviron_buf_size)>>2)] = bufSize;
- return 0;
- }
-
- function _exit(status) {
- // void _exit(int status);
- // http://pubs.opengroup.org/onlinepubs/000095399/functions/exit.html
- exit(status);
- }
-
- function _fd_close(fd) {
- return 0;
- }
-
- function _fd_fdstat_get(fd, pbuf) {
- // hack to support printf in SYSCALLS_REQUIRE_FILESYSTEM=0
- var type = fd == 1 || fd == 2 ? 2 : abort();
- HEAP8[((pbuf)>>0)] = type;
- // TODO HEAP16[(((pbuf)+(2))>>1)] = ?;
- // TODO (tempI64 = [?>>>0,(tempDouble=?,(+(Math.abs(tempDouble))) >= 1.0 ? (tempDouble > 0.0 ? ((Math.min((+(Math.floor((tempDouble)/4294967296.0))), 4294967295.0))|0)>>>0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble)))>>>0))/4294967296.0)))))>>>0) : 0)],HEAP32[(((pbuf)+(8))>>2)] = tempI64[0],HEAP32[(((pbuf)+(12))>>2)] = tempI64[1]);
- // TODO (tempI64 = [?>>>0,(tempDouble=?,(+(Math.abs(tempDouble))) >= 1.0 ? (tempDouble > 0.0 ? ((Math.min((+(Math.floor((tempDouble)/4294967296.0))), 4294967295.0))|0)>>>0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble)))>>>0))/4294967296.0)))))>>>0) : 0)],HEAP32[(((pbuf)+(16))>>2)] = tempI64[0],HEAP32[(((pbuf)+(20))>>2)] = tempI64[1]);
- return 0;
- }
-
- function _fd_read(fd, iov, iovcnt, pnum) {
- var stream = SYSCALLS.getStreamFromFD(fd);
- var num = SYSCALLS.doReadv(stream, iov, iovcnt);
- HEAP32[((pnum)>>2)] = num;
- return 0;
- }
-
- function _fd_seek(fd, offset_low, offset_high, whence, newOffset) {
- }
-
- function flush_NO_FILESYSTEM() {
- // flush anything remaining in the buffers during shutdown
- if (typeof _fflush !== 'undefined') _fflush(0);
- var buffers = SYSCALLS.buffers;
- if (buffers[1].length) SYSCALLS.printChar(1, 10);
- if (buffers[2].length) SYSCALLS.printChar(2, 10);
- }
- function _fd_write(fd, iov, iovcnt, pnum) {
- ;
- // hack to support printf in SYSCALLS_REQUIRE_FILESYSTEM=0
- var num = 0;
- for (var i = 0; i < iovcnt; i++) {
- var ptr = HEAP32[((iov)>>2)];
- var len = HEAP32[(((iov)+(4))>>2)];
- iov += 8;
- for (var j = 0; j < len; j++) {
- SYSCALLS.printChar(fd, HEAPU8[ptr+j]);
- }
- num += len;
- }
- HEAP32[((pnum)>>2)] = num;
- return 0;
- }
-
- function _gettimeofday(ptr) {
- var now = Date.now();
- HEAP32[((ptr)>>2)] = (now/1000)|0; // seconds
- HEAP32[(((ptr)+(4))>>2)] = ((now % 1000)*1000)|0; // microseconds
- return 0;
- }
-
-
-
- function _mktime(tmPtr) {
- _tzset();
- var date = new Date(HEAP32[(((tmPtr)+(20))>>2)] + 1900,
- HEAP32[(((tmPtr)+(16))>>2)],
- HEAP32[(((tmPtr)+(12))>>2)],
- HEAP32[(((tmPtr)+(8))>>2)],
- HEAP32[(((tmPtr)+(4))>>2)],
- HEAP32[((tmPtr)>>2)],
- 0);
-
- // There's an ambiguous hour when the time goes back; the tm_isdst field is
- // used to disambiguate it. Date() basically guesses, so we fix it up if it
- // guessed wrong, or fill in tm_isdst with the guess if it's -1.
- var dst = HEAP32[(((tmPtr)+(32))>>2)];
- var guessedOffset = date.getTimezoneOffset();
- var start = new Date(date.getFullYear(), 0, 1);
- var summerOffset = new Date(date.getFullYear(), 6, 1).getTimezoneOffset();
- var winterOffset = start.getTimezoneOffset();
- var dstOffset = Math.min(winterOffset, summerOffset); // DST is in December in South
- if (dst < 0) {
- // Attention: some regions don't have DST at all.
- HEAP32[(((tmPtr)+(32))>>2)] = Number(summerOffset != winterOffset && dstOffset == guessedOffset);
- } else if ((dst > 0) != (dstOffset == guessedOffset)) {
- var nonDstOffset = Math.max(winterOffset, summerOffset);
- var trueOffset = dst > 0 ? dstOffset : nonDstOffset;
- // Don't try setMinutes(date.getMinutes() + ...) -- it's messed up.
- date.setTime(date.getTime() + (trueOffset - guessedOffset)*60000);
- }
-
- HEAP32[(((tmPtr)+(24))>>2)] = date.getDay();
- var yday = ((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))|0;
- HEAP32[(((tmPtr)+(28))>>2)] = yday;
- // To match expected behavior, update fields from date
- HEAP32[((tmPtr)>>2)] = date.getSeconds();
- HEAP32[(((tmPtr)+(4))>>2)] = date.getMinutes();
- HEAP32[(((tmPtr)+(8))>>2)] = date.getHours();
- HEAP32[(((tmPtr)+(12))>>2)] = date.getDate();
- HEAP32[(((tmPtr)+(16))>>2)] = date.getMonth();
-
- return (date.getTime() / 1000)|0;
- }
-
- function _setTempRet0(val) {
- setTempRet0(val);
- }
-
- function __isLeapYear(year) {
- return year%4 === 0 && (year%100 !== 0 || year%400 === 0);
- }
-
- function __arraySum(array, index) {
- var sum = 0;
- for (var i = 0; i <= index; sum += array[i++]) {
- // no-op
- }
- return sum;
- }
-
- var __MONTH_DAYS_LEAP = [31,29,31,30,31,30,31,31,30,31,30,31];
-
- var __MONTH_DAYS_REGULAR = [31,28,31,30,31,30,31,31,30,31,30,31];
- function __addDays(date, days) {
- var newDate = new Date(date.getTime());
- while (days > 0) {
- var leap = __isLeapYear(newDate.getFullYear());
- var currentMonth = newDate.getMonth();
- var daysInCurrentMonth = (leap ? __MONTH_DAYS_LEAP : __MONTH_DAYS_REGULAR)[currentMonth];
-
- if (days > daysInCurrentMonth-newDate.getDate()) {
- // we spill over to next month
- days -= (daysInCurrentMonth-newDate.getDate()+1);
- newDate.setDate(1);
- if (currentMonth < 11) {
- newDate.setMonth(currentMonth+1)
- } else {
- newDate.setMonth(0);
- newDate.setFullYear(newDate.getFullYear()+1);
- }
- } else {
- // we stay in current month
- newDate.setDate(newDate.getDate()+days);
- return newDate;
- }
- }
-
- return newDate;
- }
- function _strftime(s, maxsize, format, tm) {
- // size_t strftime(char *restrict s, size_t maxsize, const char *restrict format, const struct tm *restrict timeptr);
- // http://pubs.opengroup.org/onlinepubs/009695399/functions/strftime.html
-
- var tm_zone = HEAP32[(((tm)+(40))>>2)];
-
- var date = {
- tm_sec: HEAP32[((tm)>>2)],
- tm_min: HEAP32[(((tm)+(4))>>2)],
- tm_hour: HEAP32[(((tm)+(8))>>2)],
- tm_mday: HEAP32[(((tm)+(12))>>2)],
- tm_mon: HEAP32[(((tm)+(16))>>2)],
- tm_year: HEAP32[(((tm)+(20))>>2)],
- tm_wday: HEAP32[(((tm)+(24))>>2)],
- tm_yday: HEAP32[(((tm)+(28))>>2)],
- tm_isdst: HEAP32[(((tm)+(32))>>2)],
- tm_gmtoff: HEAP32[(((tm)+(36))>>2)],
- tm_zone: tm_zone ? UTF8ToString(tm_zone) : ''
- };
-
- var pattern = UTF8ToString(format);
-
- // expand format
- var EXPANSION_RULES_1 = {
- '%c': '%a %b %d %H:%M:%S %Y', // Replaced by the locale's appropriate date and time representation - e.g., Mon Aug 3 14:02:01 2013
- '%D': '%m/%d/%y', // Equivalent to %m / %d / %y
- '%F': '%Y-%m-%d', // Equivalent to %Y - %m - %d
- '%h': '%b', // Equivalent to %b
- '%r': '%I:%M:%S %p', // Replaced by the time in a.m. and p.m. notation
- '%R': '%H:%M', // Replaced by the time in 24-hour notation
- '%T': '%H:%M:%S', // Replaced by the time
- '%x': '%m/%d/%y', // Replaced by the locale's appropriate date representation
- '%X': '%H:%M:%S', // Replaced by the locale's appropriate time representation
- // Modified Conversion Specifiers
- '%Ec': '%c', // Replaced by the locale's alternative appropriate date and time representation.
- '%EC': '%C', // Replaced by the name of the base year (period) in the locale's alternative representation.
- '%Ex': '%m/%d/%y', // Replaced by the locale's alternative date representation.
- '%EX': '%H:%M:%S', // Replaced by the locale's alternative time representation.
- '%Ey': '%y', // Replaced by the offset from %EC (year only) in the locale's alternative representation.
- '%EY': '%Y', // Replaced by the full alternative year representation.
- '%Od': '%d', // Replaced by the day of the month, using the locale's alternative numeric symbols, filled as needed with leading zeros if there is any alternative symbol for zero; otherwise, with leading characters.
- '%Oe': '%e', // Replaced by the day of the month, using the locale's alternative numeric symbols, filled as needed with leading characters.
- '%OH': '%H', // Replaced by the hour (24-hour clock) using the locale's alternative numeric symbols.
- '%OI': '%I', // Replaced by the hour (12-hour clock) using the locale's alternative numeric symbols.
- '%Om': '%m', // Replaced by the month using the locale's alternative numeric symbols.
- '%OM': '%M', // Replaced by the minutes using the locale's alternative numeric symbols.
- '%OS': '%S', // Replaced by the seconds using the locale's alternative numeric symbols.
- '%Ou': '%u', // Replaced by the weekday as a number in the locale's alternative representation (Monday=1).
- '%OU': '%U', // Replaced by the week number of the year (Sunday as the first day of the week, rules corresponding to %U ) using the locale's alternative numeric symbols.
- '%OV': '%V', // Replaced by the week number of the year (Monday as the first day of the week, rules corresponding to %V ) using the locale's alternative numeric symbols.
- '%Ow': '%w', // Replaced by the number of the weekday (Sunday=0) using the locale's alternative numeric symbols.
- '%OW': '%W', // Replaced by the week number of the year (Monday as the first day of the week) using the locale's alternative numeric symbols.
- '%Oy': '%y', // Replaced by the year (offset from %C ) using the locale's alternative numeric symbols.
- };
- for (var rule in EXPANSION_RULES_1) {
- pattern = pattern.replace(new RegExp(rule, 'g'), EXPANSION_RULES_1[rule]);
- }
-
- var WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
- var MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
-
- function leadingSomething(value, digits, character) {
- var str = typeof value === 'number' ? value.toString() : (value || '');
- while (str.length < digits) {
- str = character[0]+str;
- }
- return str;
- }
-
- function leadingNulls(value, digits) {
- return leadingSomething(value, digits, '0');
- }
-
- function compareByDay(date1, date2) {
- function sgn(value) {
- return value < 0 ? -1 : (value > 0 ? 1 : 0);
- }
-
- var compare;
- if ((compare = sgn(date1.getFullYear()-date2.getFullYear())) === 0) {
- if ((compare = sgn(date1.getMonth()-date2.getMonth())) === 0) {
- compare = sgn(date1.getDate()-date2.getDate());
- }
- }
- return compare;
- }
-
- function getFirstWeekStartDate(janFourth) {
- switch (janFourth.getDay()) {
- case 0: // Sunday
- return new Date(janFourth.getFullYear()-1, 11, 29);
- case 1: // Monday
- return janFourth;
- case 2: // Tuesday
- return new Date(janFourth.getFullYear(), 0, 3);
- case 3: // Wednesday
- return new Date(janFourth.getFullYear(), 0, 2);
- case 4: // Thursday
- return new Date(janFourth.getFullYear(), 0, 1);
- case 5: // Friday
- return new Date(janFourth.getFullYear()-1, 11, 31);
- case 6: // Saturday
- return new Date(janFourth.getFullYear()-1, 11, 30);
- }
- }
-
- function getWeekBasedYear(date) {
- var thisDate = __addDays(new Date(date.tm_year+1900, 0, 1), date.tm_yday);
-
- var janFourthThisYear = new Date(thisDate.getFullYear(), 0, 4);
- var janFourthNextYear = new Date(thisDate.getFullYear()+1, 0, 4);
-
- var firstWeekStartThisYear = getFirstWeekStartDate(janFourthThisYear);
- var firstWeekStartNextYear = getFirstWeekStartDate(janFourthNextYear);
-
- if (compareByDay(firstWeekStartThisYear, thisDate) <= 0) {
- // this date is after the start of the first week of this year
- if (compareByDay(firstWeekStartNextYear, thisDate) <= 0) {
- return thisDate.getFullYear()+1;
- } else {
- return thisDate.getFullYear();
- }
- } else {
- return thisDate.getFullYear()-1;
- }
- }
-
- var EXPANSION_RULES_2 = {
- '%a': function(date) {
- return WEEKDAYS[date.tm_wday].substring(0,3);
- },
- '%A': function(date) {
- return WEEKDAYS[date.tm_wday];
- },
- '%b': function(date) {
- return MONTHS[date.tm_mon].substring(0,3);
- },
- '%B': function(date) {
- return MONTHS[date.tm_mon];
- },
- '%C': function(date) {
- var year = date.tm_year+1900;
- return leadingNulls((year/100)|0,2);
- },
- '%d': function(date) {
- return leadingNulls(date.tm_mday, 2);
- },
- '%e': function(date) {
- return leadingSomething(date.tm_mday, 2, ' ');
- },
- '%g': function(date) {
- // %g, %G, and %V give values according to the ISO 8601:2000 standard week-based year.
- // In this system, weeks begin on a Monday and week 1 of the year is the week that includes
- // January 4th, which is also the week that includes the first Thursday of the year, and
- // is also the first week that contains at least four days in the year.
- // If the first Monday of January is the 2nd, 3rd, or 4th, the preceding days are part of
- // the last week of the preceding year; thus, for Saturday 2nd January 1999,
- // %G is replaced by 1998 and %V is replaced by 53. If December 29th, 30th,
- // or 31st is a Monday, it and any following days are part of week 1 of the following year.
- // Thus, for Tuesday 30th December 1997, %G is replaced by 1998 and %V is replaced by 01.
-
- return getWeekBasedYear(date).toString().substring(2);
- },
- '%G': function(date) {
- return getWeekBasedYear(date);
- },
- '%H': function(date) {
- return leadingNulls(date.tm_hour, 2);
- },
- '%I': function(date) {
- var twelveHour = date.tm_hour;
- if (twelveHour == 0) twelveHour = 12;
- else if (twelveHour > 12) twelveHour -= 12;
- return leadingNulls(twelveHour, 2);
- },
- '%j': function(date) {
- // Day of the year (001-366)
- return leadingNulls(date.tm_mday+__arraySum(__isLeapYear(date.tm_year+1900) ? __MONTH_DAYS_LEAP : __MONTH_DAYS_REGULAR, date.tm_mon-1), 3);
- },
- '%m': function(date) {
- return leadingNulls(date.tm_mon+1, 2);
- },
- '%M': function(date) {
- return leadingNulls(date.tm_min, 2);
- },
- '%n': function() {
- return '\n';
- },
- '%p': function(date) {
- if (date.tm_hour >= 0 && date.tm_hour < 12) {
- return 'AM';
- } else {
- return 'PM';
- }
- },
- '%S': function(date) {
- return leadingNulls(date.tm_sec, 2);
- },
- '%t': function() {
- return '\t';
- },
- '%u': function(date) {
- return date.tm_wday || 7;
- },
- '%U': function(date) {
- // Replaced by the week number of the year as a decimal number [00,53].
- // The first Sunday of January is the first day of week 1;
- // days in the new year before this are in week 0. [ tm_year, tm_wday, tm_yday]
- var janFirst = new Date(date.tm_year+1900, 0, 1);
- var firstSunday = janFirst.getDay() === 0 ? janFirst : __addDays(janFirst, 7-janFirst.getDay());
- var endDate = new Date(date.tm_year+1900, date.tm_mon, date.tm_mday);
-
- // is target date after the first Sunday?
- if (compareByDay(firstSunday, endDate) < 0) {
- // calculate difference in days between first Sunday and endDate
- var februaryFirstUntilEndMonth = __arraySum(__isLeapYear(endDate.getFullYear()) ? __MONTH_DAYS_LEAP : __MONTH_DAYS_REGULAR, endDate.getMonth()-1)-31;
- var firstSundayUntilEndJanuary = 31-firstSunday.getDate();
- var days = firstSundayUntilEndJanuary+februaryFirstUntilEndMonth+endDate.getDate();
- return leadingNulls(Math.ceil(days/7), 2);
- }
-
- return compareByDay(firstSunday, janFirst) === 0 ? '01': '00';
- },
- '%V': function(date) {
- // Replaced by the week number of the year (Monday as the first day of the week)
- // as a decimal number [01,53]. If the week containing 1 January has four
- // or more days in the new year, then it is considered week 1.
- // Otherwise, it is the last week of the previous year, and the next week is week 1.
- // Both January 4th and the first Thursday of January are always in week 1. [ tm_year, tm_wday, tm_yday]
- var janFourthThisYear = new Date(date.tm_year+1900, 0, 4);
- var janFourthNextYear = new Date(date.tm_year+1901, 0, 4);
-
- var firstWeekStartThisYear = getFirstWeekStartDate(janFourthThisYear);
- var firstWeekStartNextYear = getFirstWeekStartDate(janFourthNextYear);
-
- var endDate = __addDays(new Date(date.tm_year+1900, 0, 1), date.tm_yday);
-
- if (compareByDay(endDate, firstWeekStartThisYear) < 0) {
- // if given date is before this years first week, then it belongs to the 53rd week of last year
- return '53';
- }
-
- if (compareByDay(firstWeekStartNextYear, endDate) <= 0) {
- // if given date is after next years first week, then it belongs to the 01th week of next year
- return '01';
- }
-
- // given date is in between CW 01..53 of this calendar year
- var daysDifference;
- if (firstWeekStartThisYear.getFullYear() < date.tm_year+1900) {
- // first CW of this year starts last year
- daysDifference = date.tm_yday+32-firstWeekStartThisYear.getDate()
- } else {
- // first CW of this year starts this year
- daysDifference = date.tm_yday+1-firstWeekStartThisYear.getDate();
- }
- return leadingNulls(Math.ceil(daysDifference/7), 2);
- },
- '%w': function(date) {
- return date.tm_wday;
- },
- '%W': function(date) {
- // Replaced by the week number of the year as a decimal number [00,53].
- // The first Monday of January is the first day of week 1;
- // days in the new year before this are in week 0. [ tm_year, tm_wday, tm_yday]
- var janFirst = new Date(date.tm_year, 0, 1);
- var firstMonday = janFirst.getDay() === 1 ? janFirst : __addDays(janFirst, janFirst.getDay() === 0 ? 1 : 7-janFirst.getDay()+1);
- var endDate = new Date(date.tm_year+1900, date.tm_mon, date.tm_mday);
-
- // is target date after the first Monday?
- if (compareByDay(firstMonday, endDate) < 0) {
- var februaryFirstUntilEndMonth = __arraySum(__isLeapYear(endDate.getFullYear()) ? __MONTH_DAYS_LEAP : __MONTH_DAYS_REGULAR, endDate.getMonth()-1)-31;
- var firstMondayUntilEndJanuary = 31-firstMonday.getDate();
- var days = firstMondayUntilEndJanuary+februaryFirstUntilEndMonth+endDate.getDate();
- return leadingNulls(Math.ceil(days/7), 2);
- }
- return compareByDay(firstMonday, janFirst) === 0 ? '01': '00';
- },
- '%y': function(date) {
- // Replaced by the last two digits of the year as a decimal number [00,99]. [ tm_year]
- return (date.tm_year+1900).toString().substring(2);
- },
- '%Y': function(date) {
- // Replaced by the year as a decimal number (for example, 1997). [ tm_year]
- return date.tm_year+1900;
- },
- '%z': function(date) {
- // Replaced by the offset from UTC in the ISO 8601:2000 standard format ( +hhmm or -hhmm ).
- // For example, "-0430" means 4 hours 30 minutes behind UTC (west of Greenwich).
- var off = date.tm_gmtoff;
- var ahead = off >= 0;
- off = Math.abs(off) / 60;
- // convert from minutes into hhmm format (which means 60 minutes = 100 units)
- off = (off / 60)*100 + (off % 60);
- return (ahead ? '+' : '-') + String("0000" + off).slice(-4);
- },
- '%Z': function(date) {
- return date.tm_zone;
- },
- '%%': function() {
- return '%';
- }
- };
- for (var rule in EXPANSION_RULES_2) {
- if (pattern.includes(rule)) {
- pattern = pattern.replace(new RegExp(rule, 'g'), EXPANSION_RULES_2[rule](date));
- }
- }
-
- var bytes = intArrayFromString(pattern, false);
- if (bytes.length > maxsize) {
- return 0;
- }
-
- writeArrayToMemory(bytes, s);
- return bytes.length-1;
- }
-
- function _strftime_l(s, maxsize, format, tm) {
- return _strftime(s, maxsize, format, tm); // no locale support yet
- }
-
- function _time(ptr) {
- ;
- var ret = (Date.now()/1000)|0;
- if (ptr) {
- HEAP32[((ptr)>>2)] = ret;
- }
- return ret;
- }
-
-InternalError = Module['InternalError'] = extendError(Error, 'InternalError');;
-embind_init_charCodes();
-BindingError = Module['BindingError'] = extendError(Error, 'BindingError');;
-init_ClassHandle();
-init_RegisteredPointer();
-init_embind();;
-UnboundTypeError = Module['UnboundTypeError'] = extendError(Error, 'UnboundTypeError');;
-init_emval();;
-var ASSERTIONS = false;
-
-
-
-/** @type {function(string, boolean=, number=)} */
-function intArrayFromString(stringy, dontAddNull, length) {
- var len = length > 0 ? length : lengthBytesUTF8(stringy)+1;
- var u8array = new Array(len);
- var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length);
- if (dontAddNull) u8array.length = numBytesWritten;
- return u8array;
-}
-
-function intArrayToString(array) {
- var ret = [];
- for (var i = 0; i < array.length; i++) {
- var chr = array[i];
- if (chr > 0xFF) {
- if (ASSERTIONS) {
- assert(false, 'Character code ' + chr + ' (' + String.fromCharCode(chr) + ') at offset ' + i + ' not in 0x00-0xFF.');
- }
- chr &= 0xFF;
- }
- ret.push(String.fromCharCode(chr));
- }
- return ret.join('');
-}
-
-
-var asmLibraryArg = {
- "__asyncjs__wasm_ffmpeg_fopen_sync": __asyncjs__wasm_ffmpeg_fopen_sync,
- "__asyncjs__wasm_ffmpeg_fread_sync": __asyncjs__wasm_ffmpeg_fread_sync,
- "__cxa_allocate_exception": ___cxa_allocate_exception,
- "__cxa_atexit": ___cxa_atexit,
- "__cxa_throw": ___cxa_throw,
- "__gmtime_r": ___gmtime_r,
- "__localtime_r": ___localtime_r,
- "__syscall__newselect": ___syscall__newselect,
- "__syscall_fcntl64": ___syscall_fcntl64,
- "__syscall_ioctl": ___syscall_ioctl,
- "__syscall_mkdir": ___syscall_mkdir,
- "__syscall_open": ___syscall_open,
- "__syscall_rmdir": ___syscall_rmdir,
- "__syscall_unlink": ___syscall_unlink,
- "_embind_finalize_value_object": __embind_finalize_value_object,
- "_embind_register_bigint": __embind_register_bigint,
- "_embind_register_bool": __embind_register_bool,
- "_embind_register_class": __embind_register_class,
- "_embind_register_class_constructor": __embind_register_class_constructor,
- "_embind_register_class_function": __embind_register_class_function,
- "_embind_register_emval": __embind_register_emval,
- "_embind_register_float": __embind_register_float,
- "_embind_register_function": __embind_register_function,
- "_embind_register_integer": __embind_register_integer,
- "_embind_register_memory_view": __embind_register_memory_view,
- "_embind_register_std_string": __embind_register_std_string,
- "_embind_register_std_wstring": __embind_register_std_wstring,
- "_embind_register_value_object": __embind_register_value_object,
- "_embind_register_value_object_field": __embind_register_value_object_field,
- "_embind_register_void": __embind_register_void,
- "abort": _abort,
- "clock": _clock,
- "clock_gettime": _clock_gettime,
- "emscripten_asm_const_int": _emscripten_asm_const_int,
- "emscripten_get_now": _emscripten_get_now,
- "emscripten_memcpy_big": _emscripten_memcpy_big,
- "emscripten_resize_heap": _emscripten_resize_heap,
- "environ_get": _environ_get,
- "environ_sizes_get": _environ_sizes_get,
- "exit": _exit,
- "fd_close": _fd_close,
- "fd_fdstat_get": _fd_fdstat_get,
- "fd_read": _fd_read,
- "fd_seek": _fd_seek,
- "fd_write": _fd_write,
- "gettimeofday": _gettimeofday,
- "gmtime_r": _gmtime_r,
- "localtime_r": _localtime_r,
- "mktime": _mktime,
- "setTempRet0": _setTempRet0,
- "strftime": _strftime,
- "strftime_l": _strftime_l,
- "time": _time
-};
-var asm = createWasm();
-/** @type {function(...*):?} */
-var ___wasm_call_ctors = Module["___wasm_call_ctors"] = function() {
- return (___wasm_call_ctors = Module["___wasm_call_ctors"] = Module["asm"]["__wasm_call_ctors"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var ___getTypeName = Module["___getTypeName"] = function() {
- return (___getTypeName = Module["___getTypeName"] = Module["asm"]["__getTypeName"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var ___embind_register_native_and_builtin_types = Module["___embind_register_native_and_builtin_types"] = function() {
- return (___embind_register_native_and_builtin_types = Module["___embind_register_native_and_builtin_types"] = Module["asm"]["__embind_register_native_and_builtin_types"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _free = Module["_free"] = function() {
- return (_free = Module["_free"] = Module["asm"]["free"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _malloc = Module["_malloc"] = function() {
- return (_malloc = Module["_malloc"] = Module["asm"]["malloc"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var ___errno_location = Module["___errno_location"] = function() {
- return (___errno_location = Module["___errno_location"] = Module["asm"]["__errno_location"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var __get_tzname = Module["__get_tzname"] = function() {
- return (__get_tzname = Module["__get_tzname"] = Module["asm"]["_get_tzname"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var __get_daylight = Module["__get_daylight"] = function() {
- return (__get_daylight = Module["__get_daylight"] = Module["asm"]["_get_daylight"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var __get_timezone = Module["__get_timezone"] = function() {
- return (__get_timezone = Module["__get_timezone"] = Module["asm"]["_get_timezone"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var stackSave = Module["stackSave"] = function() {
- return (stackSave = Module["stackSave"] = Module["asm"]["stackSave"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var stackRestore = Module["stackRestore"] = function() {
- return (stackRestore = Module["stackRestore"] = Module["asm"]["stackRestore"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var stackAlloc = Module["stackAlloc"] = function() {
- return (stackAlloc = Module["stackAlloc"] = Module["asm"]["stackAlloc"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _emscripten_stack_set_limits = Module["_emscripten_stack_set_limits"] = function() {
- return (_emscripten_stack_set_limits = Module["_emscripten_stack_set_limits"] = Module["asm"]["emscripten_stack_set_limits"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _emscripten_stack_get_base = Module["_emscripten_stack_get_base"] = function() {
- return (_emscripten_stack_get_base = Module["_emscripten_stack_get_base"] = Module["asm"]["emscripten_stack_get_base"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _emscripten_stack_get_end = Module["_emscripten_stack_get_end"] = function() {
- return (_emscripten_stack_get_end = Module["_emscripten_stack_get_end"] = Module["asm"]["emscripten_stack_get_end"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _memalign = Module["_memalign"] = function() {
- return (_memalign = Module["_memalign"] = Module["asm"]["memalign"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_vi = Module["dynCall_vi"] = function() {
- return (dynCall_vi = Module["dynCall_vi"] = Module["asm"]["dynCall_vi"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_ii = Module["dynCall_ii"] = function() {
- return (dynCall_ii = Module["dynCall_ii"] = Module["asm"]["dynCall_ii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_viiii = Module["dynCall_viiii"] = function() {
- return (dynCall_viiii = Module["dynCall_viiii"] = Module["asm"]["dynCall_viiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiii = Module["dynCall_iiii"] = function() {
- return (dynCall_iiii = Module["dynCall_iiii"] = Module["asm"]["dynCall_iiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iii = Module["dynCall_iii"] = function() {
- return (dynCall_iii = Module["dynCall_iii"] = Module["asm"]["dynCall_iii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_vii = Module["dynCall_vii"] = function() {
- return (dynCall_vii = Module["dynCall_vii"] = Module["asm"]["dynCall_vii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_viiiiifiii = Module["dynCall_viiiiifiii"] = function() {
- return (dynCall_viiiiifiii = Module["dynCall_viiiiifiii"] = Module["asm"]["dynCall_viiiiifiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_i = Module["dynCall_i"] = function() {
- return (dynCall_i = Module["dynCall_i"] = Module["asm"]["dynCall_i"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_viii = Module["dynCall_viii"] = function() {
- return (dynCall_viii = Module["dynCall_viii"] = Module["asm"]["dynCall_viii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiiifiii = Module["dynCall_iiiiiifiii"] = function() {
- return (dynCall_iiiiiifiii = Module["dynCall_iiiiiifiii"] = Module["asm"]["dynCall_iiiiiifiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_v = Module["dynCall_v"] = function() {
- return (dynCall_v = Module["dynCall_v"] = Module["asm"]["dynCall_v"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiii = Module["dynCall_iiiiii"] = function() {
- return (dynCall_iiiiii = Module["dynCall_iiiiii"] = Module["asm"]["dynCall_iiiiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiiii = Module["dynCall_iiiiiii"] = function() {
- return (dynCall_iiiiiii = Module["dynCall_iiiiiii"] = Module["asm"]["dynCall_iiiiiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_ijiii = Module["dynCall_ijiii"] = function() {
- return (dynCall_ijiii = Module["dynCall_ijiii"] = Module["asm"]["dynCall_ijiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_jiji = Module["dynCall_jiji"] = function() {
- return (dynCall_jiji = Module["dynCall_jiji"] = Module["asm"]["dynCall_jiji"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiji = Module["dynCall_iiiji"] = function() {
- return (dynCall_iiiji = Module["dynCall_iiiji"] = Module["asm"]["dynCall_iiiji"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_viiiiii = Module["dynCall_viiiiii"] = function() {
- return (dynCall_viiiiii = Module["dynCall_viiiiii"] = Module["asm"]["dynCall_viiiiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiii = Module["dynCall_iiiii"] = function() {
- return (dynCall_iiiii = Module["dynCall_iiiii"] = Module["asm"]["dynCall_iiiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_dd = Module["dynCall_dd"] = function() {
- return (dynCall_dd = Module["dynCall_dd"] = Module["asm"]["dynCall_dd"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iidiiii = Module["dynCall_iidiiii"] = function() {
- return (dynCall_iidiiii = Module["dynCall_iidiiii"] = Module["asm"]["dynCall_iidiiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_viijii = Module["dynCall_viijii"] = function() {
- return (dynCall_viijii = Module["dynCall_viijii"] = Module["asm"]["dynCall_viijii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiiiiii = Module["dynCall_iiiiiiiii"] = function() {
- return (dynCall_iiiiiiiii = Module["dynCall_iiiiiiiii"] = Module["asm"]["dynCall_iiiiiiiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiij = Module["dynCall_iiiiij"] = function() {
- return (dynCall_iiiiij = Module["dynCall_iiiiij"] = Module["asm"]["dynCall_iiiiij"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiid = Module["dynCall_iiiiid"] = function() {
- return (dynCall_iiiiid = Module["dynCall_iiiiid"] = Module["asm"]["dynCall_iiiiid"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiijj = Module["dynCall_iiiiijj"] = function() {
- return (dynCall_iiiiijj = Module["dynCall_iiiiijj"] = Module["asm"]["dynCall_iiiiijj"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiiiii = Module["dynCall_iiiiiiii"] = function() {
- return (dynCall_iiiiiiii = Module["dynCall_iiiiiiii"] = Module["asm"]["dynCall_iiiiiiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = function() {
- return (dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = Module["asm"]["dynCall_iiiiiijj"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var dynCall_viiiii = Module["dynCall_viiiii"] = function() {
- return (dynCall_viiiii = Module["dynCall_viiiii"] = Module["asm"]["dynCall_viiiii"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _asyncify_start_unwind = Module["_asyncify_start_unwind"] = function() {
- return (_asyncify_start_unwind = Module["_asyncify_start_unwind"] = Module["asm"]["asyncify_start_unwind"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _asyncify_stop_unwind = Module["_asyncify_stop_unwind"] = function() {
- return (_asyncify_stop_unwind = Module["_asyncify_stop_unwind"] = Module["asm"]["asyncify_stop_unwind"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _asyncify_start_rewind = Module["_asyncify_start_rewind"] = function() {
- return (_asyncify_start_rewind = Module["_asyncify_start_rewind"] = Module["asm"]["asyncify_start_rewind"]).apply(null, arguments);
-};
-
-/** @type {function(...*):?} */
-var _asyncify_stop_rewind = Module["_asyncify_stop_rewind"] = function() {
- return (_asyncify_stop_rewind = Module["_asyncify_stop_rewind"] = Module["asm"]["asyncify_stop_rewind"]).apply(null, arguments);
-};
-
-
-
-
-
-// === Auto-generated postamble setup entry stuff ===
-
-Module["ccall"] = ccall;
-Module["cwrap"] = cwrap;
-
-var calledRun;
-
-/**
- * @constructor
- * @this {ExitStatus}
- */
-function ExitStatus(status) {
- this.name = "ExitStatus";
- this.message = "Program terminated with exit(" + status + ")";
- this.status = status;
-}
-
-var calledMain = false;
-
-dependenciesFulfilled = function runCaller() {
- // If run has never been called, and we should call run (INVOKE_RUN is true, and Module.noInitialRun is not false)
- if (!calledRun) run();
- if (!calledRun) dependenciesFulfilled = runCaller; // try this again later, after new deps are fulfilled
-};
-
-/** @type {function(Array=)} */
-function run(args) {
- args = args || arguments_;
-
- if (runDependencies > 0) {
- return;
- }
-
- preRun();
-
- // a preRun added a dependency, run will be called later
- if (runDependencies > 0) {
- return;
- }
-
- function doRun() {
- // run may have just been called through dependencies being fulfilled just in this very frame,
- // or while the async setStatus time below was happening
- if (calledRun) return;
- calledRun = true;
- Module['calledRun'] = true;
-
- if (ABORT) return;
-
- initRuntime();
-
- if (Module['onRuntimeInitialized']) Module['onRuntimeInitialized']();
-
- postRun();
- }
-
- if (Module['setStatus']) {
- Module['setStatus']('Running...');
- setTimeout(function() {
- setTimeout(function() {
- Module['setStatus']('');
- }, 1);
- doRun();
- }, 1);
- } else
- {
- doRun();
- }
-}
-Module['run'] = run;
-
-/** @param {boolean|number=} implicit */
-function exit(status, implicit) {
- EXITSTATUS = status;
-
- if (keepRuntimeAlive()) {
- } else {
- exitRuntime();
- }
-
- procExit(status);
-}
-
-function procExit(code) {
- EXITSTATUS = code;
- if (!keepRuntimeAlive()) {
- if (Module['onExit']) Module['onExit'](code);
- ABORT = true;
- }
- quit_(code, new ExitStatus(code));
-}
-
-if (Module['preInit']) {
- if (typeof Module['preInit'] == 'function') Module['preInit'] = [Module['preInit']];
- while (Module['preInit'].length > 0) {
- Module['preInit'].pop()();
- }
-}
-
-run();
-
-
-
-
-
diff --git a/electron/assets/wasm/wasm_video_decode.wasm b/electron/assets/wasm/wasm_video_decode.wasm
deleted file mode 100644
index 839c4d2..0000000
Binary files a/electron/assets/wasm/wasm_video_decode.wasm and /dev/null differ
diff --git a/electron/dualReportWorker.ts b/electron/dualReportWorker.ts
deleted file mode 100644
index 67e36e8..0000000
--- a/electron/dualReportWorker.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { parentPort, workerData } from 'worker_threads'
-import { wcdbService } from './services/wcdbService'
-import { dualReportService } from './services/dualReportService'
-
-interface WorkerConfig {
- year: number
- friendUsername: string
- dbPath: string
- decryptKey: string
- myWxid: string
- resourcesPath?: string
- userDataPath?: string
- logEnabled?: boolean
- excludeWords?: string[]
-}
-
-const config = workerData as WorkerConfig
-process.env.WEFLOW_WORKER = '1'
-if (config.resourcesPath) {
- process.env.WCDB_RESOURCES_PATH = config.resourcesPath
-}
-
-wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
-wcdbService.setLogEnabled(config.logEnabled === true)
-
-async function run() {
- const result = await dualReportService.generateReportWithConfig({
- year: config.year,
- friendUsername: config.friendUsername,
- dbPath: config.dbPath,
- decryptKey: config.decryptKey,
- wxid: config.myWxid,
- excludeWords: config.excludeWords,
- onProgress: (status: string, progress: number) => {
- parentPort?.postMessage({
- type: 'dualReport:progress',
- data: { status, progress }
- })
- }
- })
-
- parentPort?.postMessage({ type: 'dualReport:result', data: result })
-}
-
-run().catch((err) => {
- parentPort?.postMessage({ type: 'dualReport:error', error: String(err) })
-})
diff --git a/electron/entitlements.mac.plist b/electron/entitlements.mac.plist
deleted file mode 100644
index 02af842..0000000
--- a/electron/entitlements.mac.plist
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- com.apple.security.cs.debugger
-
- com.apple.security.get-task-allow
-
- com.apple.security.cs.allow-unsigned-executable-memory
-
- com.apple.security.cs.disable-library-validation
-
-
-
diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts
deleted file mode 100644
index dd55157..0000000
--- a/electron/exportWorker.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-import { parentPort, workerData } from 'worker_threads'
-
-interface ExportWorkerConfig {
- mode?: 'sessions' | 'single' | 'contacts'
- sessionIds?: string[]
- sessionId?: string
- outputDir?: string
- outputPath?: string
- options?: any
- taskId?: string
- dbPath?: string
- decryptKey?: string
- myWxid?: string
- imageXorKey?: unknown
- imageAesKey?: string
- resourcesPath?: string
- userDataPath?: string
- logEnabled?: boolean
- isPackaged?: boolean
-}
-
-const config = workerData as ExportWorkerConfig
-const controlState = {
- pauseRequested: false,
- stopRequested: false
-}
-
-const CREATED_PATH_FLUSH_INTERVAL_MS = 200
-const CREATED_PATH_BATCH_LIMIT = 256
-const PROGRESS_POST_INTERVAL_MS = 180
-let queuedCreatedFiles: string[] = []
-let queuedCreatedDirs: string[] = []
-let createdPathFlushTimer: ReturnType | null = null
-let pendingProgress: any = null
-let progressPostTimer: ReturnType | null = null
-let lastProgressPostedAt = 0
-
-function flushCreatedPaths() {
- if (createdPathFlushTimer) {
- clearTimeout(createdPathFlushTimer)
- createdPathFlushTimer = null
- }
- const filePaths = queuedCreatedFiles
- const dirPaths = queuedCreatedDirs
- queuedCreatedFiles = []
- queuedCreatedDirs = []
- if (!parentPort) return
- if (filePaths.length > 0) {
- parentPort.postMessage({ type: 'export:createdFiles', filePaths })
- }
- if (dirPaths.length > 0) {
- parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
- }
-}
-
-function scheduleCreatedPathFlush() {
- if (createdPathFlushTimer) return
- createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
-}
-
-function queueCreatedFile(filePath: string) {
- const normalized = String(filePath || '').trim()
- if (!normalized) return
- queuedCreatedFiles.push(normalized)
- if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
- flushCreatedPaths()
- } else {
- scheduleCreatedPathFlush()
- }
-}
-
-function queueCreatedDir(dirPath: string) {
- const normalized = String(dirPath || '').trim()
- if (!normalized) return
- queuedCreatedDirs.push(normalized)
- if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
- flushCreatedPaths()
- } else {
- scheduleCreatedPathFlush()
- }
-}
-
-function flushProgress() {
- if (!pendingProgress) return
- if (progressPostTimer) {
- clearTimeout(progressPostTimer)
- progressPostTimer = null
- }
- parentPort?.postMessage({
- type: 'export:progress',
- data: pendingProgress
- })
- pendingProgress = null
- lastProgressPostedAt = Date.now()
-}
-
-function queueProgress(progress: any) {
- pendingProgress = progress
- if (progress?.phase === 'complete') {
- flushProgress()
- return
- }
-
- const now = Date.now()
- const elapsed = now - lastProgressPostedAt
- if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
- flushProgress()
- return
- }
-
- if (progressPostTimer) return
- progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
-}
-
-parentPort?.on('message', (message: any) => {
- if (!message || typeof message.type !== 'string') return
- if (message.type === 'export:pause') {
- controlState.pauseRequested = true
- return
- }
- if (message.type === 'export:resume') {
- controlState.pauseRequested = false
- return
- }
- if (message.type === 'export:cancel') {
- controlState.stopRequested = true
- controlState.pauseRequested = false
- }
-})
-
-process.env.WEFLOW_WORKER = '1'
-if (config.resourcesPath) {
- process.env.WCDB_RESOURCES_PATH = config.resourcesPath
-}
-if (config.userDataPath) {
- process.env.WEFLOW_USER_DATA_PATH = config.userDataPath
- process.env.WEFLOW_CONFIG_CWD = config.userDataPath
-}
-process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
-
-async function run() {
- const [{ wcdbService }, { exportService }] = await Promise.all([
- import('./services/wcdbService'),
- import('./services/exportService')
- ])
-
- wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
- wcdbService.setLogEnabled(config.logEnabled === true)
- exportService.setRuntimeConfig({
- dbPath: config.dbPath,
- decryptKey: config.decryptKey,
- myWxid: config.myWxid,
- imageXorKey: config.imageXorKey,
- imageAesKey: config.imageAesKey,
- resourcesPath: config.resourcesPath,
- appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname,
- isPackaged: config.isPackaged
- })
-
- const onProgress = (progress: any) => queueProgress(progress)
-
- const taskControl = config.taskId
- ? {
- shouldPause: () => controlState.pauseRequested,
- shouldStop: () => controlState.stopRequested,
- recordCreatedFile: queueCreatedFile,
- recordCreatedDir: queueCreatedDir
- }
- : undefined
-
- let result: any
- if (config.mode === 'contacts') {
- const [{ contactExportService }, { chatService }] = await Promise.all([
- import('./services/contactExportService'),
- import('./services/chatService')
- ])
- chatService.setRuntimeConfig({
- dbPath: config.dbPath,
- decryptKey: config.decryptKey,
- myWxid: config.myWxid,
- resourcesPath: config.resourcesPath,
- appPath: config.resourcesPath ? require('path').dirname(config.resourcesPath) : __dirname,
- isPackaged: config.isPackaged
- })
- result = await contactExportService.exportContacts(
- String(config.outputDir || ''),
- config.options || {}
- )
- } else if (config.mode === 'single') {
- result = await exportService.exportSessionToChatLab(
- String(config.sessionId || '').trim(),
- String(config.outputPath || '').trim(),
- config.options || { format: 'chatlab' },
- onProgress,
- taskControl
- )
- } else {
- result = await exportService.exportSessions(
- Array.isArray(config.sessionIds) ? config.sessionIds : [],
- String(config.outputDir || ''),
- config.options || { format: 'json' },
- onProgress,
- taskControl
- )
- }
-
- flushProgress()
- flushCreatedPaths()
-
- parentPort?.postMessage({
- type: 'export:result',
- data: result
- })
-}
-
-run().catch((error) => {
- flushProgress()
- flushCreatedPaths()
- parentPort?.postMessage({
- type: 'export:error',
- error: String(error)
- })
-})
diff --git a/electron/imageSearchWorker.ts b/electron/imageSearchWorker.ts
deleted file mode 100644
index 6107dd2..0000000
--- a/electron/imageSearchWorker.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { parentPort, workerData } from 'worker_threads'
-import { readdirSync, statSync } from 'fs'
-import { join } from 'path'
-
-type WorkerPayload = {
- root: string
- datName: string
- maxDepth: number
- allowThumbnail: boolean
- thumbOnly: boolean
-}
-
-type Candidate = { score: number; path: string; isThumb: boolean }
-
-const payload = workerData as WorkerPayload
-
-function looksLikeMd5(value: string): boolean {
- return /^[a-fA-F0-9]{16,32}$/.test(value)
-}
-
-function stripDatVariantSuffix(base: string): string {
- const lower = base.toLowerCase()
- const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
- for (const suffix of suffixes) {
- if (lower.endsWith(suffix)) {
- return lower.slice(0, -suffix.length)
- }
- }
- if (/[._][a-z]$/.test(lower)) {
- return lower.slice(0, -2)
- }
- return lower
-}
-
-function hasXVariant(baseLower: string): boolean {
- return stripDatVariantSuffix(baseLower) !== baseLower
-}
-
-function hasImageVariantSuffix(baseLower: string): boolean {
- return stripDatVariantSuffix(baseLower) !== baseLower
-}
-
-function normalizeDatBase(name: string): string {
- let base = name.toLowerCase()
- if (base.endsWith('.dat') || base.endsWith('.jpg')) {
- base = base.slice(0, -4)
- }
- while (true) {
- const stripped = stripDatVariantSuffix(base)
- if (stripped === base) {
- return base
- }
- base = stripped
- }
-}
-
-function isLikelyImageDatBase(baseLower: string): boolean {
- return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower))
-}
-
-function matchesDatName(fileName: string, datName: string): boolean {
- const lower = fileName.toLowerCase()
- const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
- const normalizedBase = normalizeDatBase(base)
- const normalizedTarget = normalizeDatBase(datName.toLowerCase())
- if (normalizedBase === normalizedTarget) return true
- return lower.endsWith('.dat') && lower.includes(normalizedTarget)
-}
-
-function scoreDatName(fileName: string): number {
- const lower = fileName.toLowerCase()
- const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
- if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
- if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
- if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
- if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
- if (!hasXVariant(baseLower)) return 500
- if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
- if (isThumbnailDat(lower)) return 100
- return 350
-}
-
-function isThumbnailDat(fileName: string): boolean {
- const lower = fileName.toLowerCase()
- return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat')
-}
-
-function walkForDat(
- root: string,
- datName: string,
- maxDepth = 4,
- allowThumbnail = true,
- thumbOnly = false
-): { path: string | null; matchedBases: string[] } {
- const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]
- const candidates: Candidate[] = []
- const matchedBases = new Set()
-
- while (stack.length) {
- const current = stack.pop() as { dir: string; depth: number }
- let entries: string[]
- try {
- entries = readdirSync(current.dir)
- } catch {
- continue
- }
- for (const entry of entries) {
- const entryPath = join(current.dir, entry)
- let stat
- try {
- stat = statSync(entryPath)
- } catch {
- continue
- }
- if (stat.isDirectory()) {
- if (current.depth < maxDepth) {
- stack.push({ dir: entryPath, depth: current.depth + 1 })
- }
- continue
- }
- const lower = entry.toLowerCase()
- if (!lower.endsWith('.dat')) continue
- const baseLower = lower.slice(0, -4)
- if (!isLikelyImageDatBase(baseLower)) continue
- if (!matchesDatName(lower, datName)) continue
- matchedBases.add(baseLower)
- const isThumb = isThumbnailDat(lower)
- if (!allowThumbnail && isThumb) continue
- if (thumbOnly && !isThumb) continue
- candidates.push({
- score: scoreDatName(lower),
- path: entryPath,
- isThumb
- })
- }
- }
- if (!candidates.length) {
- return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
- }
-
- const nonThumb = candidates.filter((item) => !item.isThumb)
- const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates)
-
- let best: { score: number; path: string } | null = null
- for (const item of finalPool) {
- if (!best || item.score > best.score) {
- best = { score: item.score, path: item.path }
- }
- }
- return { path: best?.path ?? null, matchedBases: Array.from(matchedBases).slice(0, 20) }
-}
-
-function run() {
- const result = walkForDat(
- payload.root,
- payload.datName,
- payload.maxDepth,
- payload.allowThumbnail,
- payload.thumbOnly
- )
- parentPort?.postMessage({
- type: 'done',
- path: result.path,
- root: payload.root,
- datName: payload.datName,
- matchedBases: result.matchedBases
- })
-}
-
-try {
- run()
-} catch (err) {
- parentPort?.postMessage({ type: 'error', error: String(err) })
-}
diff --git a/electron/main.ts b/electron/main.ts
deleted file mode 100644
index 9c932ba..0000000
--- a/electron/main.ts
+++ /dev/null
@@ -1,4486 +0,0 @@
-import './preload-env'
-import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron'
-import { Worker } from 'worker_threads'
-import { randomUUID } from 'crypto'
-import { join, dirname } from 'path'
-import { autoUpdater } from 'electron-updater'
-import { readFile, writeFile, mkdir, rm, readdir, copyFile } from 'fs/promises'
-import { existsSync } from 'fs'
-import { ConfigService } from './services/config'
-import { dbPathService } from './services/dbPathService'
-import { wcdbService } from './services/wcdbService'
-import { chatService } from './services/chatService'
-import { imageDecryptService } from './services/imageDecryptService'
-import { imagePreloadService } from './services/imagePreloadService'
-import { analyticsService } from './services/analyticsService'
-import { groupAnalyticsService } from './services/groupAnalyticsService'
-import { annualReportService } from './services/annualReportService'
-import { exportService, ExportOptions, ExportProgress } from './services/exportService'
-import { exportTaskControlService } from './services/exportTaskControlService'
-import { KeyService } from './services/keyService'
-import { KeyServiceLinux } from './services/keyServiceLinux'
-import { KeyServiceMac } from './services/keyServiceMac'
-import { voiceTranscribeService } from './services/voiceTranscribeService'
-import { videoService } from './services/videoService'
-import { snsService, isVideoUrl } from './services/snsService'
-import { windowsHelloService } from './services/windowsHelloService'
-import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
-import { cloudControlService } from './services/cloudControlService'
-
-import { destroyNotificationWindow, registerNotificationHandlers, showNotification, setNotificationNavigateHandler } from './windows/notificationWindow'
-import { httpService } from './services/httpService'
-import { messagePushService } from './services/messagePushService'
-import { insightService } from './services/insightService'
-import { insightRecordService } from './services/insightRecordService'
-import { insightProfileService } from './services/insightProfileService'
-import { groupSummaryService } from './services/groupSummaryService'
-import { normalizeWeiboCookieInput, weiboService } from './services/social/weiboService'
-import { bizService } from './services/bizService'
-import { backupService } from './services/backupService'
-import { imageDownloadService } from './services/imageDownloadService'
-
-// 配置自动更新
-autoUpdater.autoDownload = false
-autoUpdater.autoInstallOnAppQuit = true
-autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
-// 更新通道策略:
-// - 稳定版(如 4.3.0)默认走 latest
-// - 预览版(如 0.26.2)默认走 preview(0.年.当年发布序号)
-// - 开发版(如 26.4.5)默认走 dev(年.月.日)
-// - 用户可在设置页切换稳定/预览/开发,切换后即时生效
-// 同时区分 Windows x64 / arm64,避免更新清单互相覆盖。
-const appVersion = app.getVersion()
-const inferUpdateTrackFromVersion = (version: string): 'stable' | 'preview' | 'dev' => {
- const normalized = String(version || '').trim().replace(/^v/i, '')
- if (/^0\.\d{2}\.\d+$/i.test(normalized)) return 'preview'
- if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(normalized)) return 'dev'
- // 兼容旧版命名(如 4.3.0-preview.26.1 / 4.3.0-dev.26.3.4)
- if (/-preview\.\d+\.\d+$/i.test(normalized)) return 'preview'
- if (/-dev\.\d+\.\d+\.\d+$/i.test(normalized)) return 'dev'
- // 兼容 alpha/beta/rc 预发布
- if (/(alpha|beta|rc)/i.test(normalized)) return 'dev'
- return 'stable'
-}
-
-const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
- const inferred = inferUpdateTrackFromVersion(appVersion)
- if (inferred === 'preview' || inferred === 'dev') return inferred
- return 'stable'
-})()
-let configService: ConfigService | null = null
-const activeExportWorkers = new Map()
-const activeExportTasks = new Set()
-
-const normalizeExportTaskId = (taskId: unknown): string => String(taskId || '').trim()
-
-const postExportWorkerControl = (taskId: string, action: 'pause' | 'resume' | 'cancel') => {
- const worker = activeExportWorkers.get(taskId)
- if (!worker) return
- try {
- worker.postMessage({ type: `export:${action}` })
- } catch (error) {
- console.warn(`[export-task-control] failed to post ${action} to worker:`, error)
- }
-}
-
-const finalizeExportTaskControlResult = async (taskId: string, result: any) => {
- if (!taskId) return result
- if (result?.stopped) {
- const cleanup = await exportTaskControlService.cleanupTask(taskId)
- if (!cleanup.success) {
- return {
- ...result,
- success: false,
- error: `导出已停止,但清理已导出文件失败:${cleanup.error || '未知错误'}`
- }
- }
- return {
- ...result,
- cleanup
- }
- }
- if (!result?.paused) {
- exportTaskControlService.releaseTask(taskId)
- }
- return result
-}
-
-const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
- if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
- return null
-}
-
-const getEffectiveUpdateTrack = (): 'stable' | 'preview' | 'dev' => {
- const configuredTrack = normalizeUpdateTrack(configService?.get('updateChannel'))
- return configuredTrack || defaultUpdateTrack
-}
-
-const isRemoteVersionNewer = (latestVersion: string, currentVersion: string): boolean => {
- const latest = String(latestVersion || '').trim()
- const current = String(currentVersion || '').trim()
- if (!latest || !current) return false
-
- const parseVersion = (version: string) => {
- const normalized = version.replace(/^v/i, '')
- const [main, pre = ''] = normalized.split('-', 2)
- const core = main.split('.').map((segment) => Number.parseInt(segment, 10) || 0)
- const prerelease = pre ? pre.split('.').map((segment) => /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : segment) : []
- return { core, prerelease }
- }
-
- const compareParsedVersion = (a: ReturnType, b: ReturnType): number => {
- const maxLen = Math.max(a.core.length, b.core.length)
- for (let i = 0; i < maxLen; i += 1) {
- const left = a.core[i] || 0
- const right = b.core[i] || 0
- if (left > right) return 1
- if (left < right) return -1
- }
-
- const aPre = a.prerelease
- const bPre = b.prerelease
- if (aPre.length === 0 && bPre.length === 0) return 0
- if (aPre.length === 0) return 1
- if (bPre.length === 0) return -1
-
- const preMaxLen = Math.max(aPre.length, bPre.length)
- for (let i = 0; i < preMaxLen; i += 1) {
- const left = aPre[i]
- const right = bPre[i]
- if (left === undefined) return -1
- if (right === undefined) return 1
- if (left === right) continue
-
- const leftNum = typeof left === 'number'
- const rightNum = typeof right === 'number'
- if (leftNum && rightNum) return left > right ? 1 : -1
- if (leftNum) return -1
- if (rightNum) return 1
- return String(left) > String(right) ? 1 : -1
- }
-
- return 0
- }
-
- try {
- return autoUpdater.currentVersion.compare(latest) < 0
- } catch {
- return compareParsedVersion(parseVersion(latest), parseVersion(current)) > 0
- }
-}
-
-const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string): boolean => {
- if (isRemoteVersionNewer(latestVersion, currentVersion)) return true
- const effectiveTrack = getEffectiveUpdateTrack()
- const currentTrack = inferUpdateTrackFromVersion(currentVersion)
- // 切换通道后,目标通道最新版本与当前版本不同即提示更新(即使是降级)
- if (effectiveTrack !== currentTrack && latestVersion !== currentVersion) return true
- return false
-}
-
-let lastAppliedUpdaterChannel: string | null = null
-let lastAppliedUpdaterFeedUrl: string | null = null
-const resetUpdaterProviderCache = () => {
- const updater = autoUpdater as any
- // electron-updater 会缓存 provider;切换 channel 后需清理缓存,避免仍请求旧通道
- for (const key of ['clientPromise', '_clientPromise', 'updateInfoAndProvider']) {
- if (Object.prototype.hasOwnProperty.call(updater, key)) {
- updater[key] = null
- }
- }
-}
-
-const getUpdaterFeedUrlByTrack = (track: 'stable' | 'preview' | 'dev'): string => {
- const repoBase = 'https://github.com/hicccc77/WeFlow/releases'
- if (track === 'stable') return `${repoBase}/latest/download`
- if (track === 'preview') return `${repoBase}/download/nightly-preview`
- return `${repoBase}/download/nightly-dev`
-}
-
-const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
- const track = getEffectiveUpdateTrack()
- const currentTrack = inferUpdateTrackFromVersion(appVersion)
- const baseUpdateChannel = track === 'stable' ? 'latest' : track
- const nextFeedUrl = getUpdaterFeedUrlByTrack(track)
- const nextUpdaterChannel =
- process.platform === 'win32' && process.arch === 'arm64'
- ? `${baseUpdateChannel}-arm64`
- : baseUpdateChannel
- if (
- (lastAppliedUpdaterChannel && lastAppliedUpdaterChannel !== nextUpdaterChannel) ||
- (lastAppliedUpdaterFeedUrl && lastAppliedUpdaterFeedUrl !== nextFeedUrl)
- ) {
- resetUpdaterProviderCache()
- }
- autoUpdater.allowPrerelease = track !== 'stable'
- // 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级)
- autoUpdater.allowDowngrade = track !== currentTrack
- // 统一走 generic feed,确保 preview/dev 命中各自固定发布页,不受 GitHub provider 的 prerelease 选择影响。
- autoUpdater.setFeedURL({
- provider: 'generic',
- url: nextFeedUrl,
- channel: nextUpdaterChannel
- })
- autoUpdater.channel = nextUpdaterChannel
- lastAppliedUpdaterChannel = nextUpdaterChannel
- lastAppliedUpdaterFeedUrl = nextFeedUrl
-}
-
-applyAutoUpdateChannel('startup')
-const AUTO_UPDATE_ENABLED =
- process.env.AUTO_UPDATE_ENABLED === 'true' ||
- process.env.AUTO_UPDATE_ENABLED === '1' ||
- (process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
-
-const getLaunchAtStartupUnsupportedReason = (): string | null => {
- if (process.platform !== 'win32' && process.platform !== 'darwin') {
- return '当前平台暂不支持开机自启动'
- }
- if (!app.isPackaged) {
- return '仅安装后的 Windows / macOS 版本支持开机自启动'
- }
- return null
-}
-
-const isLaunchAtStartupSupported = (): boolean => getLaunchAtStartupUnsupportedReason() == null
-
-const getStoredLaunchAtStartupPreference = (): boolean | undefined => {
- const value = configService?.get('launchAtStartup')
- return typeof value === 'boolean' ? value : undefined
-}
-
-const getSystemLaunchAtStartup = (): boolean => {
- if (!isLaunchAtStartupSupported()) return false
- try {
- return app.getLoginItemSettings().openAtLogin === true
- } catch (error) {
- console.error('[WeFlow] 读取开机自启动状态失败:', error)
- return false
- }
-}
-
-const buildLaunchAtStartupSettings = (enabled: boolean): Parameters[0] =>
- process.platform === 'win32'
- ? { openAtLogin: enabled, path: process.execPath }
- : { openAtLogin: enabled }
-
-const setSystemLaunchAtStartup = (enabled: boolean): { success: boolean; enabled: boolean; error?: string } => {
- try {
- app.setLoginItemSettings(buildLaunchAtStartupSettings(enabled))
- const effectiveEnabled = app.getLoginItemSettings().openAtLogin === true
- if (effectiveEnabled !== enabled) {
- return {
- success: false,
- enabled: effectiveEnabled,
- error: '系统未接受该开机自启动设置'
- }
- }
- return { success: true, enabled: effectiveEnabled }
- } catch (error) {
- return {
- success: false,
- enabled: getSystemLaunchAtStartup(),
- error: `设置开机自启动失败: ${String((error as Error)?.message || error)}`
- }
- }
-}
-
-const getLaunchAtStartupStatus = (): { enabled: boolean; supported: boolean; reason?: string } => {
- const unsupportedReason = getLaunchAtStartupUnsupportedReason()
- if (unsupportedReason) {
- return {
- enabled: getStoredLaunchAtStartupPreference() === true,
- supported: false,
- reason: unsupportedReason
- }
- }
- return {
- enabled: getSystemLaunchAtStartup(),
- supported: true
- }
-}
-
-const applyLaunchAtStartupPreference = (
- enabled: boolean
-): { success: boolean; enabled: boolean; supported: boolean; reason?: string; error?: string } => {
- const unsupportedReason = getLaunchAtStartupUnsupportedReason()
- if (unsupportedReason) {
- return {
- success: false,
- enabled: getStoredLaunchAtStartupPreference() === true,
- supported: false,
- reason: unsupportedReason
- }
- }
-
- const result = setSystemLaunchAtStartup(enabled)
- configService?.set('launchAtStartup', result.enabled)
- return {
- ...result,
- supported: true
- }
-}
-
-const syncLaunchAtStartupPreference = () => {
- if (!configService) return
-
- const unsupportedReason = getLaunchAtStartupUnsupportedReason()
- if (unsupportedReason) return
-
- const storedPreference = getStoredLaunchAtStartupPreference()
- const systemEnabled = getSystemLaunchAtStartup()
-
- if (typeof storedPreference !== 'boolean') {
- configService.set('launchAtStartup', systemEnabled)
- return
- }
-
- if (storedPreference === systemEnabled) return
-
- const result = setSystemLaunchAtStartup(storedPreference)
- configService.set('launchAtStartup', result.enabled)
- if (!result.success && result.error) {
- console.error('[WeFlow] 同步开机自启动设置失败:', result.error)
- }
-}
-
-// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。
-// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。
-function sanitizePathEnv() {
- // 开发模式不做裁剪,避免影响本地工具链
- if (process.env.VITE_DEV_SERVER_URL) return
-
- const rawPath = process.env.PATH || process.env.Path
- if (!rawPath) return
-
- const sep = process.platform === 'win32' ? ';' : ':'
- const parts = rawPath.split(sep).filter(Boolean)
-
- const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
- const safePrefixes = [
- systemRoot,
- systemRoot ? join(systemRoot, 'System32') : '',
- systemRoot ? join(systemRoot, 'SysWOW64') : '',
- dirname(process.execPath),
- process.resourcesPath,
- join(process.resourcesPath || '', 'resources')
- ].filter(Boolean)
-
- const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
- const isSafe = (p: string) => {
- const np = normalize(p)
- return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
- }
-
- const filtered = parts.filter(isSafe)
- if (filtered.length !== parts.length) {
- const removed = parts.filter((p) => !isSafe(p))
- console.warn('[WeFlow] 使用白名单裁剪 PATH,移除目录:', removed)
- const nextPath = filtered.join(sep)
- process.env.PATH = nextPath
- process.env.Path = nextPath
- }
-}
-
-// 启动时立即清理 PATH,后续创建的 worker 也能继承安全的环境
-sanitizePathEnv()
-
-// 单例服务
-
-// 协议窗口实例
-let agreementWindow: BrowserWindow | null = null
-let onboardingWindow: BrowserWindow | null = null
-// Splash 启动窗口
-let splashWindow: BrowserWindow | null = null
-const sessionChatWindows = new Map()
-const sessionChatWindowSources = new Map()
-
-let keyService: any
-if (process.platform === 'darwin') {
- keyService = new KeyServiceMac()
-} else if (process.platform === 'linux') {
- keyService = new KeyServiceLinux()
-} else {
- keyService = new KeyService()
-}
-
-let mainWindowReady = false
-let shouldShowMain = true
-let isAppQuitting = false
-let shutdownPromise: Promise | null = null
-let tray: Tray | null = null
-let isClosePromptVisible = false
-
-interface ChatHistoryPayloadEntry {
- sessionId: string
- title?: string
- recordList: any[]
- createdAt: number
- lastAccessedAt: number
-}
-
-const chatHistoryPayloadStore = new Map()
-const chatHistoryPayloadTtlMs = 10 * 60 * 1000
-const chatHistoryPayloadMaxEntries = 20
-
-const pruneChatHistoryPayloadStore = (): void => {
- const now = Date.now()
-
- for (const [payloadId, payload] of chatHistoryPayloadStore.entries()) {
- if (now - payload.createdAt > chatHistoryPayloadTtlMs) {
- chatHistoryPayloadStore.delete(payloadId)
- }
- }
-
- while (chatHistoryPayloadStore.size > chatHistoryPayloadMaxEntries) {
- const oldestPayloadId = chatHistoryPayloadStore.keys().next().value as string | undefined
- if (!oldestPayloadId) break
- chatHistoryPayloadStore.delete(oldestPayloadId)
- }
-}
-
-type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
-type CloseRestoreMethod = 'tray' | 'dock'
-
-// 更新下载状态管理(Issue #294 修复)
-let isDownloadInProgress = false
-let downloadProgressHandler: ((progress: any) => void) | null = null
-let downloadedHandler: (() => void) | null = null
-
-const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
- const merged = (() => {
- if (typeof rawReleaseNotes === 'string') {
- return rawReleaseNotes
- }
- if (Array.isArray(rawReleaseNotes)) {
- return rawReleaseNotes
- .map((item) => {
- if (!item || typeof item !== 'object') return ''
- const note = (item as { note?: unknown }).note
- return typeof note === 'string' ? note : ''
- })
- .filter(Boolean)
- .join('\n\n')
- }
- return ''
- })()
-
- if (!merged.trim()) return ''
-
- const normalizeHeadingText = (raw: string): string => {
- return raw
- .replace(/<[^>]*>/g, ' ')
- .replace(/ /gi, ' ')
- .replace(/&/gi, '&')
- .replace(/</gi, '<')
- .replace(/>/gi, '>')
- .replace(/"/gi, '"')
- .replace(/'/gi, '\'')
- .replace(/'/gi, '\'')
- .toLowerCase()
- .replace(/[::]/g, '')
- .replace(/\s+/g, '')
- .trim()
- }
-
- const shouldStripReleaseSection = (headingRaw: string): boolean => {
- const heading = normalizeHeadingText(headingRaw)
- if (!heading) return false
- if (heading.startsWith('下载') || heading.startsWith('download')) return true
-
- if ((heading.includes('macos') || heading.startsWith('mac')) && heading.includes('安装提示')) return true
- return false
- }
-
- // 兼容 electron-updater 直接返回 HTML 的场景(含 dir/anchor 等标签嵌套)
- const removeDownloadSectionFromHtml = (input: string): string => {
- const headingPattern = /]*>([\s\S]*?)<\/h\1>/gi
- const headings: Array<{ start: number; end: number; headingText: string }> = []
- let match: RegExpExecArray | null
-
- while ((match = headingPattern.exec(input)) !== null) {
- const full = match[0]
- headings.push({
- start: match.index,
- end: match.index + full.length,
- headingText: match[2] || ''
- })
- }
-
- if (headings.length === 0) return input
-
- const rangesToRemove: Array<{ start: number; end: number }> = []
- for (let i = 0; i < headings.length; i += 1) {
- const current = headings[i]
- if (!shouldStripReleaseSection(current.headingText)) continue
-
- const nextStart = i + 1 < headings.length ? headings[i + 1].start : input.length
- rangesToRemove.push({ start: current.start, end: nextStart })
- }
-
- if (rangesToRemove.length === 0) return input
-
- let output = ''
- let cursor = 0
- for (const range of rangesToRemove) {
- output += input.slice(cursor, range.start)
- cursor = range.end
- }
- output += input.slice(cursor)
- return output
- }
-
- // 兼容 Markdown 场景(Action 最终 release note 模板)
- const removeDownloadSectionFromMarkdown = (input: string): string => {
- const lines = input.split(/\r?\n/)
- const output: string[] = []
- let skipSection = false
-
- for (const line of lines) {
- const headingMatch = line.match(/^\s*#{1,6}\s*(.+?)\s*$/)
- if (headingMatch) {
- if (shouldStripReleaseSection(headingMatch[1])) {
- skipSection = true
- continue
- }
- if (skipSection) {
- skipSection = false
- }
- }
- if (!skipSection) {
- output.push(line)
- }
- }
-
- return output.join('\n')
- }
-
- const cleaned = removeDownloadSectionFromMarkdown(removeDownloadSectionFromHtml(merged))
- // 兜底:即使没有匹配到标题,也不在弹窗展示 macOS 隔离标记清理命令
- .replace(/^[ \t>*-]*`?\s*xattr\s+-[a-z]*d[a-z]*\s+com\.apple\.quarantine[^\n]*`?\s*$/gim, '')
- .replace(/\n{3,}/g, '\n\n')
- .trim()
-
- return cleaned
-}
-
-const getDialogReleaseNotes = (rawReleaseNotes: unknown): string => {
- const track = getEffectiveUpdateTrack()
- if (track !== 'stable') {
- return '修复了一些已知问题'
- }
- return normalizeReleaseNotes(rawReleaseNotes)
-}
-
-type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
-type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
-
-interface AnnualReportYearsProgressPayload {
- years?: number[]
- done: boolean
- error?: string
- canceled?: boolean
- strategy?: AnnualReportYearsLoadStrategy
- phase?: AnnualReportYearsLoadPhase
- statusText?: string
- nativeElapsedMs?: number
- scanElapsedMs?: number
- totalElapsedMs?: number
- switched?: boolean
- nativeTimedOut?: boolean
-}
-
-interface AnnualReportYearsTaskState {
- cacheKey: string
- canceled: boolean
- done: boolean
- snapshot: AnnualReportYearsProgressPayload
- updatedAt: number
-}
-
-interface OpenSessionChatWindowOptions {
- source?: 'chat' | 'export'
- initialDisplayName?: string
- initialAvatarUrl?: string
- initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
-}
-
-const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
- return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat'
-}
-
-const normalizeSessionChatWindowOptionString = (value: unknown): string => {
- return String(value || '').trim()
-}
-
-const loadSessionChatWindowContent = (
- win: BrowserWindow,
- sessionId: string,
- source: 'chat' | 'export',
- options?: OpenSessionChatWindowOptions
-) => {
- const queryParams = new URLSearchParams({
- sessionId,
- source
- })
- const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName)
- const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl)
- const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType)
- if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName)
- if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl)
- if (initialContactType) queryParams.set('initialContactType', initialContactType)
- const query = queryParams.toString()
- if (process.env.VITE_DEV_SERVER_URL) {
- win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`)
- return
- }
- win.loadFile(join(__dirname, '../dist/index.html'), {
- hash: `/chat-window?${query}`
- })
-}
-
-const annualReportYearsLoadTasks = new Map()
-const annualReportYearsTaskByCacheKey = new Map()
-const annualReportYearsSnapshotCache = new Map()
-const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000
-
-const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => {
- const years = Array.isArray(snapshot.years) ? [...snapshot.years] : []
- return { ...snapshot, years }
-}
-
-const buildAnnualReportYearsCacheKey = (dbPath: string, wxid: string): string => {
- return `${String(dbPath || '').trim()}\u0001${String(wxid || '').trim()}`
-}
-
-const pruneAnnualReportYearsSnapshotCache = (): void => {
- const now = Date.now()
- for (const [cacheKey, entry] of annualReportYearsSnapshotCache.entries()) {
- if (now - entry.updatedAt > annualReportYearsSnapshotTtlMs) {
- annualReportYearsSnapshotCache.delete(cacheKey)
- }
- }
-}
-
-const persistAnnualReportYearsSnapshot = (
- cacheKey: string,
- taskId: string,
- snapshot: AnnualReportYearsProgressPayload
-): void => {
- annualReportYearsSnapshotCache.set(cacheKey, {
- taskId,
- snapshot: normalizeAnnualReportYearsSnapshot(snapshot),
- updatedAt: Date.now()
- })
- pruneAnnualReportYearsSnapshotCache()
-}
-
-const getAnnualReportYearsSnapshot = (
- cacheKey: string
-): { taskId: string; snapshot: AnnualReportYearsProgressPayload } | null => {
- pruneAnnualReportYearsSnapshotCache()
- const entry = annualReportYearsSnapshotCache.get(cacheKey)
- if (!entry) return null
- return {
- taskId: entry.taskId,
- snapshot: normalizeAnnualReportYearsSnapshot(entry.snapshot)
- }
-}
-
-const broadcastAnnualReportYearsProgress = (
- taskId: string,
- payload: AnnualReportYearsProgressPayload
-): void => {
- for (const win of BrowserWindow.getAllWindows()) {
- if (win.isDestroyed()) continue
- win.webContents.send('annualReport:availableYearsProgress', {
- taskId,
- ...payload
- })
- }
-}
-
-const isYearsLoadCanceled = (taskId: string): boolean => {
- const task = annualReportYearsLoadTasks.get(taskId)
- return task?.canceled === true
-}
-
-const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
- if (process.platform === 'darwin') {
- win.setWindowButtonVisibility(false)
- }
-
- const emitMaximizeState = () => {
- if (win.isDestroyed()) return
- win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen())
- }
-
- win.on('maximize', emitMaximizeState)
- win.on('unmaximize', emitMaximizeState)
- win.on('enter-full-screen', emitMaximizeState)
- win.on('leave-full-screen', emitMaximizeState)
- win.webContents.on('did-finish-load', emitMaximizeState)
-}
-
-let notificationNavigateHandlerRegistered = false
-const focusMainWindowAndNavigate = (sessionId: string): void => {
- const targetWindow = mainWindow
- if (!targetWindow || targetWindow.isDestroyed()) return
- if (targetWindow.isMinimized()) targetWindow.restore()
- targetWindow.show()
- targetWindow.focus()
- targetWindow.webContents.send('navigate-to-session', sessionId)
-}
-
-const focusMainWindowAndNavigateRoute = (route: string): void => {
- const targetWindow = mainWindow
- if (!targetWindow || targetWindow.isDestroyed()) return
- if (targetWindow.isMinimized()) targetWindow.restore()
- targetWindow.show()
- targetWindow.focus()
- targetWindow.webContents.send('navigate-to-route', route)
-}
-
-const handleNotificationClickNavigation = (payload: unknown): void => {
- if (payload && typeof payload === 'object') {
- const data = payload as { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string }
- const targetRoute = String(data.targetRoute || '').trim()
- if (targetRoute.startsWith('/')) {
- focusMainWindowAndNavigateRoute(targetRoute)
- return
- }
- if (data.channel === 'ai-insight' && data.insightRecordId) {
- focusMainWindowAndNavigateRoute(`/insight-inbox?recordId=${encodeURIComponent(String(data.insightRecordId))}`)
- return
- }
- focusMainWindowAndNavigate(String(data.sessionId || ''))
- return
- }
- focusMainWindowAndNavigate(String(payload || ''))
-}
-
-const ensureNotificationNavigateHandlerRegistered = (): void => {
- if (notificationNavigateHandlerRegistered) return
- notificationNavigateHandlerRegistered = true
- ipcMain.on('notification-clicked', (_event, payload) => {
- handleNotificationClickNavigation(payload)
- })
- setNotificationNavigateHandler((payload: unknown) => {
- handleNotificationClickNavigation(payload)
- })
-}
-
-let wechatRequestHeaderInterceptorRegistered = false
-const ensureWeChatRequestHeaderInterceptor = (): void => {
- if (wechatRequestHeaderInterceptorRegistered) return
- wechatRequestHeaderInterceptorRegistered = true
-
- session.defaultSession.webRequest.onBeforeSendHeaders(
- {
- urls: [
- '*://*.qpic.cn/*',
- '*://*.qlogo.cn/*',
- '*://*.wechat.com/*',
- '*://*.weixin.qq.com/*',
- '*://*.wx.qq.com/*'
- ]
- },
- (details, callback) => {
- details.requestHeaders['User-Agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351"
- details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
- details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
- details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
- details.requestHeaders['Connection'] = "keep-alive"
- details.requestHeaders['Range'] = "bytes=0-"
-
- let host = ''
- try {
- host = new URL(details.url).hostname.toLowerCase()
- } catch {}
- const isWxQQ = host === 'wx.qq.com' || host.endsWith('.wx.qq.com')
- details.requestHeaders['Referer'] = isWxQQ ? 'https://wx.qq.com/' : 'https://servicewechat.com/'
-
- callback({ cancel: false, requestHeaders: details.requestHeaders })
- }
- )
-}
-
-const getWindowCloseBehavior = (): WindowCloseBehavior => {
- const behavior = configService?.get('windowCloseBehavior')
- return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
-}
-
-const isSilentStartupEnabled = (): boolean => {
- return configService?.get('silentStartup') === true
-}
-
-const getCloseRestoreMethod = (): CloseRestoreMethod | null => {
- if (tray) return 'tray'
- if (process.platform === 'darwin') return 'dock'
- return null
-}
-
-const canKeepMainWindowInBackground = (): boolean => {
- return getCloseRestoreMethod() !== null
-}
-
-const getPlatformIconName = (): string => {
- if (process.platform === 'linux') return 'icon.png'
- if (process.platform === 'darwin') return 'icon.icns'
- return 'icon.ico'
-}
-
-const resolveAppIconPath = (): string => {
- const iconName = getPlatformIconName()
- if (!process.env.VITE_DEV_SERVER_URL) {
- return join(process.resourcesPath, iconName)
- }
- if (process.platform === 'darwin') {
- return join(__dirname, '../resources/icons/macos/icon.icns')
- }
- return join(__dirname, `../public/${iconName}`)
-}
-
-const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
- if (isClosePromptVisible) return
- isClosePromptVisible = true
- const restoreMethod = getCloseRestoreMethod()
- win.webContents.send('window:confirmCloseRequested', {
- canMinimizeToTray: restoreMethod !== null,
- restoreMethod: restoreMethod ?? undefined
- })
-}
-
-function createWindow(options: { autoShow?: boolean } = {}) {
- // 获取图标路径 - 打包后在 resources 目录
- const { autoShow = true } = options
- const iconPath = resolveAppIconPath()
-
- const win = new BrowserWindow({
- width: 1400,
- height: 900,
- minWidth: 1000,
- minHeight: 700,
- icon: iconPath,
- webPreferences: {
- preload: join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false,
- webSecurity: false // Allow loading local files (video playback)
- },
- titleBarStyle: 'hidden',
- titleBarOverlay: false,
- show: false
- })
- setupCustomTitleBarWindow(win)
-
- // 窗口准备好后显示
- // Splash 模式下不在这里 show,由启动流程统一控制
- win.once('ready-to-show', () => {
- mainWindowReady = true
- if (autoShow && !splashWindow) {
- win.show()
- }
- })
-
- // 开发环境加载 vite 服务器
- if (process.env.VITE_DEV_SERVER_URL) {
- win.loadURL(process.env.VITE_DEV_SERVER_URL)
-
- // 开发环境下按 F12 或 Ctrl+Shift+I 打开开发者工具
- win.webContents.on('before-input-event', (event, input) => {
- if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
- if (win.webContents.isDevToolsOpened()) {
- win.webContents.closeDevTools()
- } else {
- win.webContents.openDevTools()
- }
- event.preventDefault()
- }
- })
- } else {
- win.loadFile(join(__dirname, '../dist/index.html'))
- }
-
- // 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
- win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
- const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
- try {
- const host = new URL(url).hostname
- if (trusted.some(d => host.endsWith(d))) {
- event.preventDefault()
- callback(true)
- return
- }
- } catch {}
- callback(false)
- })
-
- win.on('close', (e) => {
- if (isAppQuitting || win !== mainWindow) return
- e.preventDefault()
- const closeBehavior = getWindowCloseBehavior()
-
- if (closeBehavior === 'quit') {
- isAppQuitting = true
- app.quit()
- return
- }
-
- if (closeBehavior === 'tray' && canKeepMainWindowInBackground()) {
- win.hide()
- return
- }
-
- requestMainWindowCloseConfirmation(win)
- })
-
- win.on('closed', () => {
- if (mainWindow !== win) return
-
- mainWindow = null
- mainWindowReady = false
- isClosePromptVisible = false
-
- if (process.platform !== 'darwin' && !isAppQuitting) {
- destroyNotificationWindow()
- if (BrowserWindow.getAllWindows().length === 0) {
- app.quit()
- }
- }
- })
-
- return win
-}
-
-/**
- * 创建用户协议窗口
- */
-function createAgreementWindow() {
- // 如果已存在,聚焦
- if (agreementWindow && !agreementWindow.isDestroyed()) {
- agreementWindow.focus()
- return agreementWindow
- }
-
- const isDev = !!process.env.VITE_DEV_SERVER_URL
- const iconPath = isDev
- ? join(__dirname, '../public/icon.ico')
- : (process.platform === 'darwin'
- ? join(process.resourcesPath, 'icon.icns')
- : join(process.resourcesPath, 'icon.ico'))
-
- const isDark = nativeTheme.shouldUseDarkColors
-
- agreementWindow = new BrowserWindow({
- width: 700,
- height: 600,
- minWidth: 500,
- minHeight: 400,
- icon: iconPath,
- webPreferences: {
- preload: join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false
- },
- titleBarStyle: 'hidden',
- titleBarOverlay: {
- color: '#00000000',
- symbolColor: isDark ? '#FFFFFF' : '#333333',
- height: 32
- },
- show: false,
- backgroundColor: isDark ? '#1A1A1A' : '#FFFFFF'
- })
-
- agreementWindow.once('ready-to-show', () => {
- agreementWindow?.show()
- })
-
- if (process.env.VITE_DEV_SERVER_URL) {
- agreementWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/agreement-window`)
- } else {
- agreementWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/agreement-window' })
- }
-
- agreementWindow.on('closed', () => {
- agreementWindow = null
- })
-
- return agreementWindow
-}
-
-/**
- * 创建 Splash 启动窗口
- * 使用纯 HTML 页面,不依赖 React,确保极速显示
- */
-function createSplashWindow(): BrowserWindow {
- const isDev = !!process.env.VITE_DEV_SERVER_URL
- const splashThemeId = configService?.get('themeId') || 'cloud-dancer'
- const splashThemeMode = configService?.get('theme') || 'system'
- const iconPath = isDev
- ? join(__dirname, '../public/icon.ico')
- : (process.platform === 'darwin'
- ? join(process.resourcesPath, 'icon.icns')
- : join(process.resourcesPath, 'icon.ico'))
-
- splashWindow = new BrowserWindow({
- width: 680,
- height: 460,
- resizable: false,
- frame: false,
- transparent: true,
- backgroundColor: '#00000000',
- hasShadow: false,
- center: true,
- skipTaskbar: false,
- icon: iconPath,
- webPreferences: {
- contextIsolation: true,
- nodeIntegration: false
- // 不需要 preload —— 通过 executeJavaScript 单向推送进度
- },
- show: false
- })
-
- if (isDev) {
- const splashUrl = new URL('splash.html', process.env.VITE_DEV_SERVER_URL)
- splashUrl.searchParams.set('themeId', splashThemeId)
- splashUrl.searchParams.set('themeMode', splashThemeMode)
- splashWindow.loadURL(splashUrl.toString())
- } else {
- splashWindow.loadFile(join(__dirname, '../dist/splash.html'), {
- query: {
- themeId: splashThemeId,
- themeMode: splashThemeMode
- }
- })
- }
-
- splashWindow.once('ready-to-show', () => {
- splashWindow?.show()
- })
-
- splashWindow.on('closed', () => {
- splashWindow = null
- })
-
- return splashWindow
-}
-
-/**
- * 向 Splash 窗口发送进度更新
- */
-function updateSplashProgress(percent: number, text: string, indeterminate = false) {
- if (splashWindow && !splashWindow.isDestroyed()) {
- splashWindow.webContents
- .executeJavaScript(`updateProgress(${percent}, ${JSON.stringify(text)}, ${indeterminate})`)
- .catch(() => {})
- }
-}
-
-/**
- * 关闭 Splash 窗口
- */
-function closeSplash() {
- if (splashWindow && !splashWindow.isDestroyed()) {
- splashWindow.close()
- splashWindow = null
- }
-}
-
-/**
- * 创建首次引导窗口
- */
-function createOnboardingWindow(mode: 'default' | 'add-account' = 'default') {
- const onboardingHash = mode === 'add-account'
- ? '/onboarding-window?mode=add-account'
- : '/onboarding-window'
-
- if (onboardingWindow && !onboardingWindow.isDestroyed()) {
- if (process.env.VITE_DEV_SERVER_URL) {
- onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`)
- } else {
- onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash })
- }
- onboardingWindow.focus()
- return onboardingWindow
- }
-
- const isDev = !!process.env.VITE_DEV_SERVER_URL
- const iconPath = isDev
- ? join(__dirname, '../public/icon.ico')
- : (process.platform === 'darwin'
- ? join(process.resourcesPath, 'icon.icns')
- : join(process.resourcesPath, 'icon.ico'))
-
- onboardingWindow = new BrowserWindow({
- width: 960,
- height: 680,
- minWidth: 900,
- minHeight: 620,
- resizable: false,
- frame: false,
- transparent: true,
- backgroundColor: '#00000000',
- hasShadow: false,
- icon: iconPath,
- webPreferences: {
- preload: join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false
- },
- show: false
- })
-
- onboardingWindow.once('ready-to-show', () => {
- onboardingWindow?.show()
- })
-
- if (process.env.VITE_DEV_SERVER_URL) {
- onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`)
- } else {
- onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash })
- }
-
- onboardingWindow.on('closed', () => {
- onboardingWindow = null
- })
-
- return onboardingWindow
-}
-
-/**
- * 创建独立的视频播放窗口
- * 窗口大小会根据视频比例自动调整
- */
-function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) {
- const isDev = !!process.env.VITE_DEV_SERVER_URL
- const iconPath = isDev
- ? join(__dirname, '../public/icon.ico')
- : (process.platform === 'darwin'
- ? join(process.resourcesPath, 'icon.icns')
- : join(process.resourcesPath, 'icon.ico'))
-
- // 获取屏幕尺寸
- const { screen } = require('electron')
- const primaryDisplay = screen.getPrimaryDisplay()
- const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
-
- // 计算窗口尺寸,只有标题栏 40px,控制栏悬浮
- let winWidth = 854
- let winHeight = 520
- const titleBarHeight = 40
-
- if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) {
- const aspectRatio = videoWidth / videoHeight
-
- const maxWidth = Math.floor(screenWidth * 0.85)
- const maxHeight = Math.floor(screenHeight * 0.85)
-
- if (aspectRatio >= 1) {
- // 横向视频
- winWidth = Math.min(videoWidth, maxWidth)
- winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
-
- if (winHeight > maxHeight) {
- winHeight = maxHeight
- winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
- }
- } else {
- // 竖向视频
- const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
- winHeight = videoDisplayHeight + titleBarHeight
- winWidth = Math.floor(videoDisplayHeight * aspectRatio)
-
- if (winWidth < 300) {
- winWidth = 300
- winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
- }
- }
-
- winWidth = Math.max(winWidth, 360)
- winHeight = Math.max(winHeight, 280)
- }
-
- const win = new BrowserWindow({
- width: winWidth,
- height: winHeight,
- minWidth: 360,
- minHeight: 280,
- icon: iconPath,
- webPreferences: {
- preload: join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false,
- webSecurity: false
- },
- titleBarStyle: 'hidden',
- titleBarOverlay: {
- color: '#1a1a1a',
- symbolColor: '#ffffff',
- height: 40
- },
- show: false,
- backgroundColor: '#000000',
- autoHideMenuBar: true
- })
-
- win.once('ready-to-show', () => {
- win.show()
- })
-
- const videoParam = `videoPath=${encodeURIComponent(videoPath)}`
- if (process.env.VITE_DEV_SERVER_URL) {
- win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`)
-
- win.webContents.on('before-input-event', (event, input) => {
- if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
- if (win.webContents.isDevToolsOpened()) {
- win.webContents.closeDevTools()
- } else {
- win.webContents.openDevTools()
- }
- event.preventDefault()
- }
- })
- } else {
- win.loadFile(join(__dirname, '../dist/index.html'), {
- hash: `/video-player-window?${videoParam}`
- })
- }
-}
-
-/**
- * 创建独立的图片查看窗口
- */
-function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
- const isDev = !!process.env.VITE_DEV_SERVER_URL
- const iconPath = isDev
- ? join(__dirname, '../public/icon.ico')
- : (process.platform === 'darwin'
- ? join(process.resourcesPath, 'icon.icns')
- : join(process.resourcesPath, 'icon.ico'))
-
- const win = new BrowserWindow({
- width: 900,
- height: 700,
- minWidth: 400,
- minHeight: 300,
- icon: iconPath,
- webPreferences: {
- preload: join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false,
- webSecurity: false // 允许加载本地文件
- },
- frame: false,
- show: false,
- backgroundColor: '#000000',
- autoHideMenuBar: true
- })
-
- setupCustomTitleBarWindow(win)
-
- win.once('ready-to-show', () => {
- win.show()
- })
-
- let imageParam = `imagePath=${encodeURIComponent(imagePath)}`
- if (liveVideoPath) imageParam += `&liveVideoPath=${encodeURIComponent(liveVideoPath)}`
-
- if (process.env.VITE_DEV_SERVER_URL) {
- win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
-
- win.webContents.on('before-input-event', (event, input) => {
- if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
- if (win.webContents.isDevToolsOpened()) {
- win.webContents.closeDevTools()
- } else {
- win.webContents.openDevTools()
- }
- event.preventDefault()
- }
- })
- } else {
- win.loadFile(join(__dirname, '../dist/index.html'), {
- hash: `/image-viewer-window?${imageParam}`
- })
- }
-
- return win
-}
-
-/**
- * 创建独立的聊天记录窗口
- */
-function createChatHistoryWindow(sessionId: string, messageId: number) {
- return createChatHistoryRouteWindow(`/chat-history/${sessionId}/${messageId}`)
-}
-
-function createChatHistoryPayloadWindow(payloadId: string) {
- const win = createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
- win.on('closed', () => {
- chatHistoryPayloadStore.delete(payloadId)
- })
- return win
-}
-
-function createChatHistoryRouteWindow(route: string) {
- const isDev = !!process.env.VITE_DEV_SERVER_URL
- const iconPath = isDev
- ? join(__dirname, '../public/icon.ico')
- : (process.platform === 'darwin'
- ? join(process.resourcesPath, 'icon.icns')
- : join(process.resourcesPath, 'icon.ico'))
-
- const win = new BrowserWindow({
- width: 600,
- height: 800,
- minWidth: 400,
- minHeight: 500,
- icon: iconPath,
- webPreferences: {
- preload: join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false
- },
- titleBarStyle: 'hidden',
- titleBarOverlay: false,
- show: false,
- backgroundColor: '#FFFFFF',
- autoHideMenuBar: true
- })
- setupCustomTitleBarWindow(win)
-
- let hasShown = false
- let isReadyToShow = false
- let hasLoadedRoute = false
- const showChatHistoryWindow = () => {
- if (hasShown || !isReadyToShow || !hasLoadedRoute || win.isDestroyed()) return
- hasShown = true
- win.show()
- }
-
- win.webContents.once('did-finish-load', () => {
- hasLoadedRoute = true
- setTimeout(showChatHistoryWindow, 30)
- })
- win.webContents.once('did-fail-load', () => {
- hasLoadedRoute = true
- showChatHistoryWindow()
- })
- win.once('ready-to-show', () => {
- isReadyToShow = true
- showChatHistoryWindow()
- })
-
- if (process.env.VITE_DEV_SERVER_URL) {
- win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${route}`)
-
- win.webContents.on('before-input-event', (event, input) => {
- if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
- if (win.webContents.isDevToolsOpened()) {
- win.webContents.closeDevTools()
- } else {
- win.webContents.openDevTools()
- }
- event.preventDefault()
- }
- })
- } else {
- win.loadFile(join(__dirname, '../dist/index.html'), {
- hash: route
- })
- }
-
- return win
-}
-
-/**
- * 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
- */
-function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) {
- const normalizedSessionId = String(sessionId || '').trim()
- if (!normalizedSessionId) return null
- const normalizedSource = normalizeSessionChatWindowSource(options?.source)
-
- const existing = sessionChatWindows.get(normalizedSessionId)
- if (existing && !existing.isDestroyed()) {
- const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
- if (trackedSource !== normalizedSource) {
- loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options)
- sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
- }
- if (existing.isMinimized()) {
- existing.restore()
- }
- existing.focus()
- return existing
- }
-
- const isDev = !!process.env.VITE_DEV_SERVER_URL
- const iconPath = isDev
- ? join(__dirname, '../public/icon.ico')
- : (process.platform === 'darwin'
- ? join(process.resourcesPath, 'icon.icns')
- : join(process.resourcesPath, 'icon.ico'))
-
- const isDark = nativeTheme.shouldUseDarkColors
-
- const win = new BrowserWindow({
- width: 600,
- height: 820,
- minWidth: 420,
- minHeight: 560,
- icon: iconPath,
- webPreferences: {
- preload: join(__dirname, 'preload.js'),
- contextIsolation: true,
- nodeIntegration: false
- },
- titleBarStyle: 'hidden',
- titleBarOverlay: {
- color: '#00000000',
- symbolColor: isDark ? '#ffffff' : '#1a1a1a',
- height: 40
- },
- show: false,
- backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
- autoHideMenuBar: true
- })
-
- loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
-
- if (process.env.VITE_DEV_SERVER_URL) {
- win.webContents.on('before-input-event', (event, input) => {
- if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
- if (win.webContents.isDevToolsOpened()) {
- win.webContents.closeDevTools()
- } else {
- win.webContents.openDevTools()
- }
- event.preventDefault()
- }
- })
- }
-
- win.once('ready-to-show', () => {
- win.show()
- win.focus()
- })
-
- win.on('closed', () => {
- const tracked = sessionChatWindows.get(normalizedSessionId)
- if (tracked === win) {
- sessionChatWindows.delete(normalizedSessionId)
- sessionChatWindowSources.delete(normalizedSessionId)
- }
- })
-
- sessionChatWindows.set(normalizedSessionId, win)
- sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
- return win
-}
-
-function showMainWindow() {
- shouldShowMain = true
- if (mainWindowReady) {
- mainWindow?.show()
- }
-}
-
-const normalizeAccountId = (value: string): string => {
- const trimmed = String(value || '').trim()
- if (!trimmed) return ''
- if (trimmed.toLowerCase().startsWith('wxid_')) {
- const match = trimmed.match(/^(wxid_[^_]+)/i)
- return match?.[1] || trimmed
- }
- const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
- return suffixMatch ? suffixMatch[1] : trimmed
-}
-
-const buildAccountNameMatcher = (wxidCandidates: string[]) => {
- const loweredCandidates = wxidCandidates
- .map((item) => String(item || '').trim().toLowerCase())
- .filter(Boolean)
- return (name: string): boolean => {
- const loweredName = String(name || '').trim().toLowerCase()
- if (!loweredName) return false
- return loweredCandidates.some((candidate) => (
- loweredName === candidate ||
- loweredName.startsWith(`${candidate}_`) ||
- loweredName.includes(candidate)
- ))
- }
-}
-
-const removePathIfExists = async (
- targetPath: string,
- removedPaths: string[],
- warnings: string[]
-): Promise => {
- if (!targetPath || !existsSync(targetPath)) return
- try {
- await rm(targetPath, { recursive: true, force: true })
- removedPaths.push(targetPath)
- } catch (error) {
- warnings.push(`${targetPath}: ${String(error)}`)
- }
-}
-
-const removeMatchedEntriesInDir = async (
- rootDir: string,
- shouldRemove: (name: string) => boolean,
- removedPaths: string[],
- warnings: string[]
-): Promise => {
- if (!rootDir || !existsSync(rootDir)) return
- try {
- const entries = await readdir(rootDir, { withFileTypes: true })
- for (const entry of entries) {
- if (!shouldRemove(entry.name)) continue
- const targetPath = join(rootDir, entry.name)
- await removePathIfExists(targetPath, removedPaths, warnings)
- }
- } catch (error) {
- warnings.push(`${rootDir}: ${String(error)}`)
- }
-}
-
-const normalizeFsPathForCompare = (value: string): string => {
- const normalized = String(value || '').replace(/\\/g, '/').replace(/\/+$/, '')
- return process.platform === 'win32' ? normalized.toLowerCase() : normalized
-}
-
-type SnsCacheMigrationCandidate = {
- label: string
- sourceDir: string
- targetDir: string
- fileCount: number
-}
-
-type SnsCacheMigrationPlan = {
- legacyBaseDir: string
- currentBaseDir: string
- candidates: SnsCacheMigrationCandidate[]
- totalFiles: number
-}
-
-type SnsCacheMigrationProgressPayload = {
- status: 'running' | 'done' | 'error'
- phase: 'copying' | 'cleanup' | 'done' | 'error'
- current: number
- total: number
- copied: number
- skipped: number
- remaining: number
- message?: string
- currentItemLabel?: string
-}
-
-let snsCacheMigrationInProgress = false
-
-const countFilesInDir = async (dirPath: string): Promise => {
- if (!dirPath || !existsSync(dirPath)) return 0
- try {
- const entries = await readdir(dirPath, { withFileTypes: true })
- let count = 0
- for (const entry of entries) {
- const fullPath = join(dirPath, entry.name)
- if (entry.isDirectory()) {
- count += await countFilesInDir(fullPath)
- continue
- }
- if (entry.isFile()) count += 1
- }
- return count
- } catch {
- return 0
- }
-}
-
-const migrateDirectoryPreserveNewFiles = async (
- sourceDir: string,
- targetDir: string,
- onFileProcessed?: (payload: { copied: boolean }) => void
-): Promise<{ copied: number; skipped: number; processed: number }> => {
- let copied = 0
- let skipped = 0
- let processed = 0
-
- if (!existsSync(sourceDir)) return { copied, skipped, processed }
- await mkdir(targetDir, { recursive: true })
-
- const entries = await readdir(sourceDir, { withFileTypes: true })
- for (const entry of entries) {
- const sourcePath = join(sourceDir, entry.name)
- const targetPath = join(targetDir, entry.name)
-
- if (entry.isDirectory()) {
- const nested = await migrateDirectoryPreserveNewFiles(sourcePath, targetPath, onFileProcessed)
- copied += nested.copied
- skipped += nested.skipped
- processed += nested.processed
- continue
- }
-
- if (!entry.isFile()) continue
-
- if (existsSync(targetPath)) {
- skipped += 1
- processed += 1
- onFileProcessed?.({ copied: false })
- continue
- }
-
- await mkdir(dirname(targetPath), { recursive: true })
- await copyFile(sourcePath, targetPath)
- copied += 1
- processed += 1
- onFileProcessed?.({ copied: true })
- }
-
- return { copied, skipped, processed }
-}
-
-const collectLegacySnsCacheMigrationPlan = async (): Promise => {
- if (!configService) return null
-
- const legacyBaseDir = configService.getCacheBasePath()
- const configuredCachePath = String(configService.get('cachePath') || '').trim()
- const currentBaseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
-
- if (!legacyBaseDir || !currentBaseDir) return null
-
- const candidates = [
- {
- label: '朋友圈媒体缓存',
- sourceDir: join(legacyBaseDir, 'sns_cache'),
- targetDir: join(currentBaseDir, 'sns_cache')
- },
- {
- label: '朋友圈表情缓存(合并到 Emojis)',
- sourceDir: join(legacyBaseDir, 'sns_emoji_cache'),
- targetDir: join(currentBaseDir, 'Emojis')
- },
- {
- label: '朋友圈表情缓存(当前目录残留)',
- sourceDir: join(currentBaseDir, 'sns_emoji_cache'),
- targetDir: join(currentBaseDir, 'Emojis')
- }
- ]
-
- const pendingKeys = new Set()
- const pending: SnsCacheMigrationCandidate[] = []
- for (const item of candidates) {
- const sourceKey = normalizeFsPathForCompare(item.sourceDir)
- const targetKey = normalizeFsPathForCompare(item.targetDir)
- if (!sourceKey || sourceKey === targetKey) continue
- const dedupeKey = `${sourceKey}=>${targetKey}`
- if (pendingKeys.has(dedupeKey)) continue
- const fileCount = await countFilesInDir(item.sourceDir)
- if (fileCount <= 0) continue
- pendingKeys.add(dedupeKey)
- pending.push({ ...item, fileCount })
- }
- if (pending.length === 0) return null
-
- const totalFiles = pending.reduce((sum, item) => sum + item.fileCount, 0)
- return {
- legacyBaseDir,
- currentBaseDir,
- candidates: pending,
- totalFiles
- }
-}
-
-const runLegacySnsCacheMigration = async (
- plan: SnsCacheMigrationPlan,
- onProgress: (payload: SnsCacheMigrationProgressPayload) => void
-): Promise<{ copied: number; skipped: number; totalFiles: number }> => {
- let processed = 0
- let copied = 0
- let skipped = 0
- const total = plan.totalFiles
-
- const emitProgress = (patch?: Partial) => {
- onProgress({
- status: 'running',
- phase: 'copying',
- current: processed,
- total,
- copied,
- skipped,
- remaining: Math.max(0, total - processed),
- ...patch
- })
- }
-
- emitProgress({ message: '准备迁移缓存...' })
-
- for (const item of plan.candidates) {
- emitProgress({ currentItemLabel: item.label, message: `正在迁移:${item.label}` })
- const result = await migrateDirectoryPreserveNewFiles(item.sourceDir, item.targetDir, ({ copied: copiedThisFile }) => {
- processed += 1
- if (copiedThisFile) copied += 1
- else skipped += 1
- emitProgress({ currentItemLabel: item.label })
- })
- // 兜底对齐计数,防止回调未触发造成偏差
- const expectedProcessed = copied + skipped
- if (processed !== expectedProcessed) {
- processed = expectedProcessed
- copied = Math.max(copied, result.copied)
- skipped = Math.max(skipped, result.skipped)
- emitProgress({ currentItemLabel: item.label })
- }
- }
-
- emitProgress({ phase: 'cleanup', message: '正在清理旧目录...' })
- for (const item of plan.candidates) {
- await rm(item.sourceDir, { recursive: true, force: true })
- }
-
- if (existsSync(plan.legacyBaseDir)) {
- try {
- const remaining = await readdir(plan.legacyBaseDir)
- if (remaining.length === 0) {
- await rm(plan.legacyBaseDir, { recursive: true, force: true })
- }
- } catch {
- // 忽略旧目录清理失败,不影响迁移结果
- }
- }
-
- onProgress({
- status: 'done',
- phase: 'done',
- current: processed,
- total,
- copied,
- skipped,
- remaining: Math.max(0, total - processed),
- message: '迁移完成'
- })
-
- return { copied, skipped, totalFiles: total }
-}
-
-// 注册 IPC 处理器
-function registerIpcHandlers() {
- registerNotificationHandlers()
- ensureNotificationNavigateHandlerRegistered()
- bizService.registerHandlers()
- // 配置相关
- ipcMain.handle('config:get', async (_, key: string) => {
- return configService?.get(key as any)
- })
-
- ipcMain.handle('config:set', async (_, key: string, value: any) => {
- let result: unknown
- if (key === 'launchAtStartup') {
- result = applyLaunchAtStartupPreference(value === true)
- } else {
- result = configService?.set(key as any, value)
- }
- if (key === 'updateChannel') {
- applyAutoUpdateChannel('settings')
- }
- void messagePushService.handleConfigChanged(key)
- void insightService.handleConfigChanged(key)
- void groupSummaryService.handleConfigChanged(key)
- return result
- })
-
- // AI 见解
- ipcMain.handle('insight:testConnection', async () => {
- return insightService.testConnection()
- })
-
- ipcMain.handle('insight:getTodayStats', async () => {
- return insightService.getTodayStats()
- })
-
- ipcMain.handle('insight:listRecords', async (_, filters?: {
- keyword?: string
- sessionId?: string
- startTime?: number
- endTime?: number
- sourceType?: 'insight' | 'message_analysis' | 'all'
- limit?: number
- offset?: number
- }) => {
- return insightRecordService.listRecords(filters || {})
- })
-
- ipcMain.handle('insight:getRecord', async (_, id: string) => {
- return insightRecordService.getRecord(id)
- })
-
- ipcMain.handle('insight:markRecordRead', async (_, id: string) => {
- return insightRecordService.markRecordRead(id)
- })
-
- ipcMain.handle('insight:clearRecords', async (_, filters?: {
- sessionId?: string
- startTime?: number
- endTime?: number
- }) => {
- return insightRecordService.clearRecords(filters || {})
- })
-
- ipcMain.handle('insight:triggerTest', async () => {
- return insightService.triggerTest()
- })
-
- ipcMain.handle('insight:triggerSessionInsight', async (_, payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- }) => {
- return insightService.triggerSessionInsight(payload)
- })
-
- ipcMain.handle('insight:listProfileStatuses', async (_, sessionIds: string[]) => {
- return insightProfileService.listProfileStatuses(Array.isArray(sessionIds) ? sessionIds : [])
- })
-
- ipcMain.handle('insight:generateProfile', async (_, payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- }) => {
- return insightProfileService.generateProfile(payload)
- })
-
- ipcMain.handle('insight:cancelProfile', async (_, sessionId?: string) => {
- return insightProfileService.cancelProfile(sessionId)
- })
-
- ipcMain.handle('insight:generateFootprintInsight', async (_, payload: {
- rangeLabel: string
- summary: {
- private_inbound_people?: number
- private_replied_people?: number
- private_outbound_people?: number
- private_reply_rate?: number
- mention_count?: number
- mention_group_count?: number
- }
- 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 }>
- }) => {
- return insightService.generateFootprintInsight(payload)
- })
-
- ipcMain.handle('insight:generateMessageInsight', async (_, payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- targetLocalId?: number
- targetCreateTime?: number
- targetMessageKey?: string
- targetText: string
- targetSenderName?: string
- contextCount?: number
- forceRefresh?: boolean
- }) => {
- return insightService.generateMessageInsight(payload)
- })
-
- ipcMain.handle('groupSummary:listRecords', async (_, filters?: {
- sessionId?: string
- startTime?: number
- endTime?: number
- limit?: number
- offset?: number
- }) => {
- return groupSummaryService.listRecords(filters || {})
- })
-
- ipcMain.handle('groupSummary:getRecord', async (_, id: string) => {
- return groupSummaryService.getRecord(id)
- })
-
- ipcMain.handle('groupSummary:triggerManual', async (_, payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- startTime: number
- endTime: number
- }) => {
- return groupSummaryService.triggerManual(payload)
- })
-
- ipcMain.handle('groupSummary:triggerDay', async (_, payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- date: string
- }) => {
- return groupSummaryService.triggerDay(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)
- if (!result.success && result.error) {
- console.error('[WeFlow] 清空配置时关闭开机自启动失败:', result.error)
- }
- }
- configService?.clear()
- messagePushService.handleConfigCleared()
- insightService.handleConfigCleared()
- groupSummaryService.handleConfigCleared()
- return true
- })
-
- // 文件对话框
- ipcMain.handle('dialog:openFile', async (_, options) => {
- const { dialog } = await import('electron')
- return dialog.showOpenDialog(options)
- })
-
- ipcMain.handle('dialog:openDirectory', async (_, options) => {
- const { dialog } = await import('electron')
- return dialog.showOpenDialog({
- properties: ['openDirectory', 'createDirectory'],
- ...options
- })
- })
-
- ipcMain.handle('dialog:saveFile', async (_, options) => {
- const { dialog } = await import('electron')
- return dialog.showSaveDialog(options)
- })
-
- ipcMain.handle('shell:openPath', async (_, path: string) => {
- const { shell } = await import('electron')
- return shell.openPath(path)
- })
-
- ipcMain.handle('shell:openExternal', async (_, url: string) => {
- const { shell } = await import('electron')
- return shell.openExternal(url)
- })
-
- ipcMain.handle('app:getDownloadsPath', async () => {
- return app.getPath('downloads')
- })
-
- ipcMain.handle('app:getVersion', async () => {
- return app.getVersion()
- })
-
- ipcMain.handle('app:getLaunchAtStartupStatus', async () => {
- return getLaunchAtStartupStatus()
- })
-
- ipcMain.handle('app:setLaunchAtStartup', async (_, enabled: boolean) => {
- return applyLaunchAtStartupPreference(enabled === true)
- })
-
- ipcMain.handle('log:getPath', async () => {
- return join(app.getPath('userData'), 'logs', 'wcdb.log')
- })
-
- ipcMain.handle('log:read', async () => {
- try {
- const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
- const content = await readFile(logPath, 'utf8')
- return { success: true, content }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- })
-
- ipcMain.handle('log:clear', async () => {
- try {
- const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
- await mkdir(dirname(logPath), { recursive: true })
- await writeFile(logPath, '', 'utf8')
- return { success: true }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- })
-
- ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
- return exportCardDiagnosticsService.snapshot(options?.limit)
- })
-
- ipcMain.handle('diagnostics:clearExportCardLogs', async () => {
- exportCardDiagnosticsService.clear()
- return { success: true }
- })
-
- ipcMain.handle('diagnostics:exportExportCardLogs', async (_, payload?: {
- filePath?: string
- frontendLogs?: unknown[]
- }) => {
- const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : ''
- if (!filePath) {
- return { success: false, error: '导出路径不能为空' }
- }
- return exportCardDiagnosticsService.exportCombinedLogs(filePath, payload?.frontendLogs || [])
- })
-
- // 数据收集服务
- ipcMain.handle('cloud:init', async () => {
- await cloudControlService.init()
- })
-
- ipcMain.handle('cloud:recordPage', (_, pageName: string) => {
- cloudControlService.recordPage(pageName)
- })
-
- ipcMain.handle('cloud:getLogs', async () => {
- return cloudControlService.getLogs()
- })
-
- ipcMain.handle('app:checkForUpdates', async () => {
- if (!AUTO_UPDATE_ENABLED) {
- return { hasUpdate: false }
- }
- // 每次主动检查前重新应用一次通道配置,确保使用最新选择的更新通道。
- applyAutoUpdateChannel('settings')
- try {
- const result = await autoUpdater.checkForUpdates()
- if (result && result.updateInfo) {
- const currentVersion = app.getVersion()
- const latestVersion = result.updateInfo.version
- if (shouldOfferUpdateForTrack(latestVersion, currentVersion)) {
- return {
- hasUpdate: true,
- version: latestVersion,
- releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes),
- minimumVersion: (result.updateInfo as any).minimumVersion
- }
- }
- }
- return { hasUpdate: false }
- } catch (error) {
- console.error('检查更新失败:', error)
- return { hasUpdate: false }
- }
- })
-
- ipcMain.handle('app:downloadAndInstall', async (event) => {
- if (!AUTO_UPDATE_ENABLED) {
- throw new Error('自动更新已暂时禁用')
- }
-
- // 防止重复下载(Issue #294 修复)
- if (isDownloadInProgress) {
- throw new Error('更新正在下载中,请稍候')
- }
-
- isDownloadInProgress = true
- const win = BrowserWindow.fromWebContents(event.sender)
-
- // 清理旧的监听器(Issue #294 修复:防止监听器泄漏)
- if (downloadProgressHandler) {
- autoUpdater.removeListener('download-progress', downloadProgressHandler)
- downloadProgressHandler = null
- }
- if (downloadedHandler) {
- autoUpdater.removeListener('update-downloaded', downloadedHandler)
- downloadedHandler = null
- }
-
- // 创建新的监听器并保存引用
- downloadProgressHandler = (progress) => {
- if (win && !win.isDestroyed()) {
- win.webContents.send('app:downloadProgress', progress)
- }
- }
-
- downloadedHandler = () => {
- console.log('[Update] 更新下载完成,准备安装')
- if (downloadProgressHandler) {
- autoUpdater.removeListener('download-progress', downloadProgressHandler)
- downloadProgressHandler = null
- }
- downloadedHandler = null
- isDownloadInProgress = false
- autoUpdater.quitAndInstall(false, true)
- }
-
- autoUpdater.on('download-progress', downloadProgressHandler)
- autoUpdater.once('update-downloaded', downloadedHandler)
-
- try {
- console.log('[Update] 开始下载更新...')
- await autoUpdater.downloadUpdate()
- } catch (error: any) {
- console.error('[Update] 下载更新失败:', error)
- // 失败时清理状态和监听器
- isDownloadInProgress = false
- if (downloadProgressHandler) {
- autoUpdater.removeListener('download-progress', downloadProgressHandler)
- downloadProgressHandler = null
- }
- if (downloadedHandler) {
- autoUpdater.removeListener('update-downloaded', downloadedHandler)
- downloadedHandler = null
- }
-
- 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 || '下载更新失败,请稍后重试')
- }
- })
-
- ipcMain.handle('app:ignoreUpdate', async (_, version: string) => {
- configService?.set('ignoredUpdateVersion', version)
- return { success: true }
- })
-
- // 窗口控制
- ipcMain.on('window:minimize', (event) => {
- BrowserWindow.fromWebContents(event.sender)?.minimize()
- })
-
- ipcMain.on('window:maximize', (event) => {
- const win = BrowserWindow.fromWebContents(event.sender)
- if (win?.isMaximized()) {
- win.unmaximize()
- } else {
- win?.maximize()
- }
- })
-
- ipcMain.handle('window:isMaximized', (event) => {
- const win = BrowserWindow.fromWebContents(event.sender)
- return Boolean(win?.isMaximized() || win?.isFullScreen())
- })
-
- ipcMain.on('window:close', (event) => {
- BrowserWindow.fromWebContents(event.sender)?.close()
- })
-
- ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => {
- if (!mainWindow || mainWindow.isDestroyed()) {
- isClosePromptVisible = false
- return false
- }
-
- try {
- if (action === 'tray') {
- if (canKeepMainWindowInBackground()) {
- mainWindow.hide()
- return true
- }
- return false
- }
-
- if (action === 'quit') {
- isAppQuitting = true
- app.quit()
- return true
- }
-
- return true
- } finally {
- isClosePromptVisible = false
- }
- })
-
- // 更新窗口控件主题色
- ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
- const win = BrowserWindow.fromWebContents(event.sender)
- if (win) {
- try {
- win.setTitleBarOverlay({
- color: '#00000000',
- symbolColor: options.symbolColor,
- height: 40
- })
- } catch (error) {
- console.warn('TitleBarOverlay not enabled for this window:', error)
- }
- }
- })
-
- // 打开视频播放窗口
- ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => {
- createVideoPlayerWindow(videoPath, videoWidth, videoHeight)
- })
-
- // 打开聊天记录窗口
- ipcMain.handle('window:openChatHistoryWindow', (_, sessionId: string, messageId: number) => {
- createChatHistoryWindow(sessionId, messageId)
- return true
- })
-
- ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => {
- const payloadId = randomUUID()
- pruneChatHistoryPayloadStore()
- const now = Date.now()
- chatHistoryPayloadStore.set(payloadId, {
- sessionId: String(payload?.sessionId || '').trim(),
- title: String(payload?.title || '').trim() || '聊天记录',
- recordList: Array.isArray(payload?.recordList) ? payload.recordList : [],
- createdAt: now,
- lastAccessedAt: now
- })
- pruneChatHistoryPayloadStore()
- createChatHistoryPayloadWindow(payloadId)
- return true
- })
-
- ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => {
- pruneChatHistoryPayloadStore()
- const normalizedPayloadId = String(payloadId || '').trim()
- const payload = chatHistoryPayloadStore.get(normalizedPayloadId)
- if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
- const nextPayload: ChatHistoryPayloadEntry = {
- ...payload,
- lastAccessedAt: Date.now()
- }
- chatHistoryPayloadStore.set(normalizedPayloadId, nextPayload)
- return {
- success: true,
- payload: {
- sessionId: nextPayload.sessionId,
- title: nextPayload.title,
- recordList: nextPayload.recordList
- }
- }
- })
-
- // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
- ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
- const win = createSessionChatWindow(sessionId, options)
- return Boolean(win)
- })
-
- // 根据视频尺寸调整窗口大小
- ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
- const win = BrowserWindow.fromWebContents(event.sender)
- if (!win || !videoWidth || !videoHeight) return
-
- const { screen } = require('electron')
- const primaryDisplay = screen.getPrimaryDisplay()
- const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
-
- // 只有标题栏 40px,控制栏悬浮在视频上
- const titleBarHeight = 40
- const aspectRatio = videoWidth / videoHeight
-
- const maxWidth = Math.floor(screenWidth * 0.85)
- const maxHeight = Math.floor(screenHeight * 0.85)
-
- let winWidth: number
- let winHeight: number
-
- if (aspectRatio >= 1) {
- // 横向视频 - 以宽度为基准
- winWidth = Math.min(videoWidth, maxWidth)
- winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
-
- if (winHeight > maxHeight) {
- winHeight = maxHeight
- winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
- }
- } else {
- // 竖向视频 - 以高度为基准
- const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
- winHeight = videoDisplayHeight + titleBarHeight
- winWidth = Math.floor(videoDisplayHeight * aspectRatio)
-
- // 确保宽度不会太窄
- if (winWidth < 300) {
- winWidth = 300
- winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
- }
- }
-
- winWidth = Math.max(winWidth, 360)
- winHeight = Math.max(winHeight, 280)
-
- // 调整窗口大小并居中
- win.setSize(winWidth, winHeight)
- win.center()
- })
-
- // 视频相关
- ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => {
- try {
- const result = await videoService.getVideoInfo(videoMd5, options)
- return { success: true, ...result }
- } catch (e) {
- return { success: false, error: String(e), exists: false }
- }
- })
-
- ipcMain.handle('video:parseVideoMd5', async (_, content: string) => {
- try {
- const md5 = videoService.parseVideoMd5(content)
- return { success: true, md5 }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- })
-
- // 数据库路径相关
- ipcMain.handle('dbpath:autoDetect', async () => {
- return dbPathService.autoDetect()
- })
-
- ipcMain.handle('dbpath:scanWxids', async (_, rootPath: string) => {
- return dbPathService.scanWxids(rootPath)
- })
-
- ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => {
- return dbPathService.scanWxidCandidates(rootPath)
- })
-
- ipcMain.handle('dbpath:getDefault', async () => {
- return dbPathService.getDefaultPath()
- })
-
- // WCDB 数据库相关
- ipcMain.handle('wcdb:testConnection', async (_, dbPath: string, hexKey: string, wxid: string) => {
- const cfg = configService || new ConfigService()
- const accountDir = cfg.getAccountDir(dbPath, wxid)
- if (!accountDir) {
- return { success: false, error: '未找到账号目录' }
- }
- return wcdbService.testConnection(accountDir, hexKey)
- })
-
- ipcMain.handle('wcdb:open', async (_, dbPath: string, hexKey: string, wxid: string) => {
- const cfg = configService || new ConfigService()
- const accountDir = cfg.getAccountDir(dbPath, wxid)
- if (!accountDir) {
- return false
- }
- return wcdbService.open(accountDir, hexKey)
- })
-
- ipcMain.handle('wcdb:close', async () => {
- wcdbService.close()
- return true
- })
-
- ipcMain.handle('backup:create', async (_, payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => {
- return backupService.createBackup(payload.outputPath, payload.options)
- })
-
- ipcMain.handle('backup:inspect', async (_, payload: { archivePath: string }) => {
- return backupService.inspectBackup(payload.archivePath)
- })
-
- ipcMain.handle('backup:restore', async (_, payload: { archivePath: string }) => {
- return backupService.restoreBackup(payload.archivePath)
- })
-
-
-
- // 聊天相关
- ipcMain.handle('chat:connect', async () => {
- return chatService.connect()
- })
-
- ipcMain.handle('chat:getSessions', async () => {
- return chatService.getSessions()
- })
-
- ipcMain.handle('chat:markAllSessionsRead', async () => {
- return chatService.markAllSessionsRead()
- })
-
- ipcMain.handle('chat:getSessionStatuses', async (_, usernames: string[]) => {
- return chatService.getSessionStatuses(usernames)
- })
-
- ipcMain.handle('chat:getExportTabCounts', async () => {
- return chatService.getExportTabCounts()
- })
-
- ipcMain.handle('chat:getContactTypeCounts', async () => {
- return chatService.getContactTypeCounts()
- })
-
- ipcMain.handle('chat:getSessionMessageCounts', async (_, sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => {
- return chatService.getSessionMessageCounts(sessionIds, options)
- })
-
- ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: {
- skipDisplayName?: boolean
- onlyMissingAvatar?: boolean
- }) => {
- return chatService.enrichSessionsContactInfo(usernames, options)
- })
-
- ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
- return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
- })
-
- ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
- return chatService.getLatestMessages(sessionId, limit)
- })
-
- ipcMain.handle('chat:getNewMessages', async (_, sessionId: string, minTime: number, limit?: number) => {
- return chatService.getNewMessages(sessionId, minTime, limit)
- })
-
- ipcMain.handle('chat:getAntiRevokeSessions', async () => {
- return chatService.getAntiRevokeSessions()
- })
-
- ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => {
- return chatService.updateMessage(sessionId, localId, createTime, newContent)
- })
-
- ipcMain.handle('chat:deleteMessage', async (_, sessionId: string, localId: number, createTime: number, dbPathHint?: string) => {
- return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint)
- })
-
- ipcMain.handle('chat:checkAntiRevokeTriggers', async (_, sessionIds: string[]) => {
- return chatService.checkAntiRevokeTriggers(sessionIds)
- })
-
- ipcMain.handle('chat:installAntiRevokeTriggers', async (_, sessionIds: string[]) => {
- return chatService.installAntiRevokeTriggers(sessionIds)
- })
-
- ipcMain.handle('chat:uninstallAntiRevokeTriggers', async (_, sessionIds: string[]) => {
- return chatService.uninstallAntiRevokeTriggers(sessionIds)
- })
-
- ipcMain.handle('chat:getContact', async (_, username: string) => {
- return await chatService.getContact(username)
- })
-
-
- ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
- return await chatService.getContactAvatar(username)
- })
-
- ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => {
- return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
- })
-
- ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => {
- return await chatService.getContacts(options)
- })
-
- ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
- return chatService.getCachedSessionMessages(sessionId)
- })
-
- ipcMain.handle('chat:getMyAvatarUrl', async () => {
- return chatService.getMyAvatarUrl()
- })
-
- ipcMain.handle('chat:downloadEmoji', async (_, cdnUrl: string, md5?: string) => {
- return chatService.downloadEmoji(cdnUrl, md5)
- })
-
- ipcMain.handle('chat:close', async () => {
- chatService.close()
- return true
- })
-
- ipcMain.handle('chat:clearCurrentAccountData', async (_, options?: { clearCache?: boolean; clearExports?: boolean }) => {
- const cfg = configService
- if (!cfg) return { success: false, error: '配置服务未初始化' }
-
- const clearCache = options?.clearCache === true
- const clearExports = options?.clearExports === true
- if (!clearCache && !clearExports) {
- return { success: false, error: '请至少选择一项清理范围' }
- }
-
- const rawWxid = String(cfg.getMyWxidCleaned() || '').trim()
- if (!rawWxid) {
- return { success: false, error: '当前账号未登录或未识别,无法清理' }
- }
- const normalizedWxid = normalizeAccountId(rawWxid)
- const wxidCandidates = Array.from(new Set([rawWxid, normalizedWxid].filter(Boolean)))
- const isMatchedAccountName = buildAccountNameMatcher(wxidCandidates)
- const removedPaths: string[] = []
- const warnings: string[] = []
-
- try {
- wcdbService.close()
- chatService.close()
- } catch (error) {
- warnings.push(`关闭数据库连接失败: ${String(error)}`)
- }
-
- if (clearCache) {
- const [analyticsResult, imageResult] = await Promise.all([
- analyticsService.clearCache(),
- imageDecryptService.clearCache()
- ])
- const chatResult = chatService.clearCaches()
- const cleanupResults = [analyticsResult, imageResult, chatResult]
- for (const result of cleanupResults) {
- if (!result.success && result.error) warnings.push(result.error)
- }
-
- const configuredCachePath = String(cfg.get('cachePath') || '').trim()
- const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow')
- const userDataCacheDir = join(app.getPath('userData'), 'cache')
- const cacheRootCandidates = [
- configuredCachePath,
- join(documentsWeFlowDir, 'Images'),
- join(documentsWeFlowDir, 'Voices'),
- join(documentsWeFlowDir, 'Emojis'),
- userDataCacheDir
- ].filter(Boolean)
-
- for (const wxid of wxidCandidates) {
- if (configuredCachePath) {
- await removePathIfExists(join(configuredCachePath, wxid), removedPaths, warnings)
- await removePathIfExists(join(configuredCachePath, 'Images', wxid), removedPaths, warnings)
- await removePathIfExists(join(configuredCachePath, 'Voices', wxid), removedPaths, warnings)
- await removePathIfExists(join(configuredCachePath, 'Emojis', wxid), removedPaths, warnings)
- }
- await removePathIfExists(join(documentsWeFlowDir, 'Images', wxid), removedPaths, warnings)
- await removePathIfExists(join(documentsWeFlowDir, 'Voices', wxid), removedPaths, warnings)
- await removePathIfExists(join(documentsWeFlowDir, 'Emojis', wxid), removedPaths, warnings)
- await removePathIfExists(join(userDataCacheDir, wxid), removedPaths, warnings)
- }
-
- for (const cacheRoot of cacheRootCandidates) {
- await removeMatchedEntriesInDir(cacheRoot, isMatchedAccountName, removedPaths, warnings)
- }
- }
-
- if (clearExports) {
- const configuredExportPath = String(cfg.get('exportPath') || '').trim()
- const documentsWeFlowDir = join(app.getPath('documents'), 'WeFlow')
- const exportRootCandidates = [
- configuredExportPath,
- join(documentsWeFlowDir, 'exports'),
- join(documentsWeFlowDir, 'Exports')
- ].filter(Boolean)
-
- for (const exportRoot of exportRootCandidates) {
- await removeMatchedEntriesInDir(exportRoot, isMatchedAccountName, removedPaths, warnings)
- }
-
- const resetConfigKeys = [
- 'exportSessionRecordMap',
- 'exportLastSessionRunMap',
- 'exportLastContentRunMap',
- 'exportSessionMessageCountCacheMap',
- 'exportSessionContentMetricCacheMap',
- 'exportSnsStatsCacheMap',
- 'snsPageCacheMap',
- 'contactsListCacheMap',
- 'contactsAvatarCacheMap',
- 'lastSession'
- ]
- for (const key of resetConfigKeys) {
- const defaultValue = key === 'lastSession' ? '' : {}
- cfg.set(key as any, defaultValue as any)
- }
-
- try {
- const dbPath = String(cfg.get('dbPath') || '').trim()
- const automationMapRaw = cfg.get('exportAutomationTaskMap') as Record | undefined
- if (automationMapRaw && typeof automationMapRaw === 'object') {
- const nextAutomationMap: Record = { ...automationMapRaw }
- let changed = false
- for (const scopeKey of Object.keys(automationMapRaw)) {
- const normalizedScopeKey = String(scopeKey || '').trim()
- if (!normalizedScopeKey) continue
- const separatorIndex = normalizedScopeKey.lastIndexOf('::')
- const scopedDbPath = separatorIndex >= 0
- ? normalizedScopeKey.slice(0, separatorIndex)
- : ''
- const scopedWxidRaw = separatorIndex >= 0
- ? normalizedScopeKey.slice(separatorIndex + 2)
- : normalizedScopeKey
- const scopedWxid = normalizeAccountId(scopedWxidRaw)
- const wxidMatched = wxidCandidates.includes(scopedWxidRaw) || scopedWxid === normalizedWxid
- const dbPathMatched = !dbPath || !scopedDbPath || scopedDbPath === dbPath
- if (!wxidMatched || !dbPathMatched) continue
- delete nextAutomationMap[scopeKey]
- changed = true
- }
- if (changed) {
- cfg.set('exportAutomationTaskMap' as any, nextAutomationMap as any)
- } else if (!Object.keys(automationMapRaw).length) {
- cfg.set('exportAutomationTaskMap' as any, {} as any)
- }
- }
- } catch (error) {
- warnings.push(`清理自动化导出任务失败: ${String(error)}`)
- }
- }
-
- if (clearCache) {
- try {
- const wxidConfigsRaw = cfg.get('wxidConfigs') as Record | undefined
- if (wxidConfigsRaw && typeof wxidConfigsRaw === 'object') {
- const nextConfigs: Record = { ...wxidConfigsRaw }
- for (const key of Object.keys(nextConfigs)) {
- if (isMatchedAccountName(key) || normalizeAccountId(key) === normalizedWxid) {
- delete nextConfigs[key]
- }
- }
- cfg.set('wxidConfigs' as any, nextConfigs as any)
- }
- cfg.set('myWxid' as any, '')
- cfg.set('decryptKey' as any, '')
- cfg.set('imageXorKey' as any, 0)
- cfg.set('imageAesKey' as any, '')
- cfg.set('dbPath' as any, '')
- cfg.set('lastOpenedDb' as any, '')
- cfg.set('onboardingDone' as any, false)
- cfg.set('lastSession' as any, '')
- } catch (error) {
- warnings.push(`清理账号配置失败: ${String(error)}`)
- }
- }
-
- return {
- success: true,
- removedPaths,
- warning: warnings.length > 0 ? warnings.join('; ') : undefined
- }
- })
-
- ipcMain.handle('chat:getSessionDetail', async (_, sessionId: string) => {
- return chatService.getSessionDetail(sessionId)
- })
-
- ipcMain.handle('chat:getSessionDetailFast', async (_, sessionId: string) => {
- return chatService.getSessionDetailFast(sessionId)
- })
-
- ipcMain.handle('chat:getSessionDetailExtra', async (_, sessionId: string) => {
- return chatService.getSessionDetailExtra(sessionId)
- })
-
- ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: {
- includeRelations?: boolean
- forceRefresh?: boolean
- allowStaleCache?: boolean
- preferAccurateSpecialTypes?: boolean
- cacheOnly?: boolean
- beginTimestamp?: number
- endTimestamp?: number
- }) => {
- return chatService.getExportSessionStats(sessionIds, options)
- })
-
- ipcMain.handle('chat:getGroupMyMessageCountHint', async (_, chatroomId: string) => {
- return chatService.getGroupMyMessageCountHint(chatroomId)
- })
-
- ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
- return chatService.getImageData(sessionId, msgId)
- })
-
- ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
- return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
- })
- ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
- return chatService.getAllVoiceMessages(sessionId)
- })
- ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => {
- return chatService.getAllImageMessages(sessionId)
- })
- ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
- return chatService.getMessageDates(sessionId)
- })
- ipcMain.handle('chat:getMessageDateCounts', async (_, sessionId: string) => {
- return chatService.getMessageDateCounts(sessionId)
- })
-
- ipcMain.handle('chat:getResourceMessages', async (_, options?: {
- sessionId?: string
- types?: Array<'image' | 'video' | 'voice' | 'file'>
- beginTimestamp?: number
- endTimestamp?: number
- limit?: number
- offset?: number
- }) => {
- return chatService.getResourceMessages(options)
- })
-
- ipcMain.handle('chat:getMediaStream', async (_, options?: {
- sessionId?: string
- mediaType?: 'image' | 'video' | 'all'
- beginTimestamp?: number
- endTimestamp?: number
- limit?: number
- offset?: number
- }) => {
- return wcdbService.getMediaStream(options)
- })
- ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
- return chatService.resolveVoiceCache(sessionId, msgId)
- })
-
- ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
- return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
- event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text })
- })
- })
-
- ipcMain.handle('chat:getMessage', async (_, sessionId: string, localId: number) => {
- return chatService.getMessageById(sessionId, localId)
- })
-
- ipcMain.handle('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => {
- return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
- })
-
- ipcMain.handle('chat:getMyFootprintStats', async (_, beginTimestamp: number, endTimestamp: number, options?: {
- myWxid?: string
- privateSessionIds?: string[]
- groupSessionIds?: string[]
- mentionLimit?: number
- privateLimit?: number
- mentionMode?: 'text_at_me' | string
- }) => {
- return chatService.getMyFootprintStats(beginTimestamp, endTimestamp, options)
- })
-
- ipcMain.handle('chat:exportMyFootprint', async (_, beginTimestamp: number, endTimestamp: number, format: 'csv' | 'json', filePath: string) => {
- return chatService.exportMyFootprint(beginTimestamp, endTimestamp, format, filePath)
- })
-
- ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
- return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
- })
-
- ipcMain.handle('sns:getSnsUsernames', async () => {
- return snsService.getSnsUsernames()
- })
-
- ipcMain.handle('sns:getUserPostCounts', async () => {
- return snsService.getUserPostCounts()
- })
-
- ipcMain.handle('sns:getExportStats', async () => {
- return snsService.getExportStats()
- })
-
- ipcMain.handle('sns:getExportStatsFast', async () => {
- return snsService.getExportStatsFast()
- })
-
- ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
- return snsService.getUserPostStats(username)
- })
-
- ipcMain.handle('sns:debugResource', async (_, url: string) => {
- return snsService.debugResource(url)
- })
-
- ipcMain.handle('sns:proxyImage', async (_, payload: string | { url: string; key?: string | number }) => {
- const url = typeof payload === 'string' ? payload : payload?.url
- const key = typeof payload === 'string' ? undefined : payload?.key
- return snsService.proxyImage(url, key)
- })
-
- ipcMain.handle('sns:downloadImage', async (_, payload: { url: string; key?: string | number }) => {
- try {
- const { url, key } = payload
- const result = await snsService.downloadImage(url, key)
-
- if (!result.success || !result.data) {
- return { success: false, error: result.error || '下载图片失败' }
- }
-
- const { dialog } = await import('electron')
- const ext = (result.contentType || '').split('/')[1] || 'jpg'
- const defaultPath = `SNS_${Date.now()}.${ext}`
-
-
- const filters = isVideoUrl(url)
- ? [{ name: 'Videos', extensions: ['mp4', 'mov', 'avi', 'mkv'] }]
- : [{ name: 'Images', extensions: [ext, 'jpg', 'jpeg', 'png', 'webp', 'gif'] }]
-
- const { filePath, canceled } = await dialog.showSaveDialog({
- defaultPath,
- filters
- })
-
- if (canceled || !filePath) {
- return { success: false, error: '用户已取消' }
- }
-
- const fs = await import('fs/promises')
- await fs.writeFile(filePath, result.data)
-
- return { success: true, filePath }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- })
-
- ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
- const exportOptions = { ...(options || {}) }
- const taskId = normalizeExportTaskId(exportOptions.taskId)
- delete exportOptions.taskId
- const taskControl = taskId ? exportTaskControlService.createControl(taskId, String(exportOptions.outputDir || '')) : undefined
- if (taskId) activeExportTasks.add(taskId)
-
- try {
- const result = await snsService.exportTimeline(
- exportOptions,
- (progress) => {
- if (!event.sender.isDestroyed()) {
- event.sender.send('sns:exportProgress', progress)
- }
- },
- taskControl
- )
- return finalizeExportTaskControlResult(taskId, result)
- } finally {
- if (taskId) activeExportTasks.delete(taskId)
- }
- })
-
- ipcMain.handle('sns:selectExportDir', async () => {
- const { dialog } = await import('electron')
- const result = await dialog.showOpenDialog({
- properties: ['openDirectory', 'createDirectory'],
- title: '选择导出目录'
- })
- if (result.canceled || !result.filePaths?.[0]) {
- return { canceled: true }
- }
- return { canceled: false, filePath: result.filePaths[0] }
- })
-
- ipcMain.handle('sns:installBlockDeleteTrigger', async () => {
- return snsService.installSnsBlockDeleteTrigger()
- })
-
- ipcMain.handle('sns:uninstallBlockDeleteTrigger', async () => {
- return snsService.uninstallSnsBlockDeleteTrigger()
- })
-
- ipcMain.handle('sns:checkBlockDeleteTrigger', async () => {
- return snsService.checkSnsBlockDeleteTrigger()
- })
-
- ipcMain.handle('sns:deleteSnsPost', async (_, postId: string) => {
- return snsService.deleteSnsPost(postId)
- })
-
- ipcMain.handle('sns:downloadEmoji', async (_, params: { url: string; encryptUrl?: string; aesKey?: string }) => {
- return snsService.downloadSnsEmoji(params.url, params.encryptUrl, params.aesKey)
- })
-
- ipcMain.handle('sns:getCacheMigrationStatus', async () => {
- try {
- const plan = await collectLegacySnsCacheMigrationPlan()
- if (!plan) {
- return {
- success: true,
- needed: false,
- inProgress: snsCacheMigrationInProgress,
- totalFiles: 0,
- items: []
- }
- }
- return {
- success: true,
- needed: true,
- inProgress: snsCacheMigrationInProgress,
- totalFiles: plan.totalFiles,
- legacyBaseDir: plan.legacyBaseDir,
- currentBaseDir: plan.currentBaseDir,
- items: plan.candidates
- }
- } catch (error) {
- return { success: false, needed: false, error: String((error as Error)?.message || error || '') }
- }
- })
-
- ipcMain.handle('sns:startCacheMigration', async (event) => {
- if (snsCacheMigrationInProgress) {
- return { success: false, error: '迁移任务正在进行中' }
- }
-
- const sender = event.sender
- let lastProgress: SnsCacheMigrationProgressPayload = {
- status: 'running',
- phase: 'copying',
- current: 0,
- total: 0,
- copied: 0,
- skipped: 0,
- remaining: 0
- }
- const emitProgress = (payload: SnsCacheMigrationProgressPayload) => {
- lastProgress = payload
- if (!sender.isDestroyed()) {
- sender.send('sns:cacheMigrationProgress', payload)
- }
- }
-
- try {
- const plan = await collectLegacySnsCacheMigrationPlan()
- if (!plan) {
- emitProgress({
- status: 'done',
- phase: 'done',
- current: 0,
- total: 0,
- copied: 0,
- skipped: 0,
- remaining: 0,
- message: '无需迁移'
- })
- return { success: true, copied: 0, skipped: 0, totalFiles: 0, message: '无需迁移' }
- }
-
- snsCacheMigrationInProgress = true
- const result = await runLegacySnsCacheMigration(plan, emitProgress)
- return { success: true, ...result }
- } catch (error) {
- const message = String((error as Error)?.message || error || '')
- emitProgress({
- ...lastProgress,
- status: 'error',
- phase: 'error',
- message
- })
- return { success: false, error: message }
- } finally {
- snsCacheMigrationInProgress = false
- }
- })
-
- // 私聊克隆
-
-
- 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)
- })
- ipcMain.handle(
- 'image:resolveCacheBatch',
- async (
- _,
- 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 : []
- 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 }
- }
- )
- ipcMain.handle(
- 'image:preload',
- async (
- _,
- 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) => {
- // 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致
- // 如果主窗口不存在(极其罕见),则回退到调用者窗口
- const targetWin = (mainWindow && !mainWindow.isDestroyed())
- ? mainWindow
- : (BrowserWindow.fromWebContents(event.sender) || undefined)
-
- const result = await windowsHelloService.verify(message, targetWin)
-
- // Hello 验证成功后,自动用 authHelloSecret 中的密码解锁密钥
- if (result && configService) {
- const secret = configService.getHelloSecret()
- if (secret && configService.isLockMode()) {
- configService.unlock(secret)
- }
- }
-
- return result
- })
-
- // 验证应用锁状态(检测 lock: 前缀,防篡改)
- ipcMain.handle('auth:verifyEnabled', async () => {
- return configService?.verifyAuthEnabled() ?? false
- })
-
- // 密码解锁(验证 + 解密密钥到内存)
- ipcMain.handle('auth:unlock', async (_event, password: string) => {
- if (!configService) return { success: false, error: '配置服务未初始化' }
- return configService.unlock(password)
- })
-
- // 开启应用锁
- ipcMain.handle('auth:enableLock', async (_event, password: string) => {
- if (!configService) return { success: false, error: '配置服务未初始化' }
- return configService.enableLock(password)
- })
-
- // 关闭应用锁
- ipcMain.handle('auth:disableLock', async (_event, password: string) => {
- if (!configService) return { success: false, error: '配置服务未初始化' }
- return configService.disableLock(password)
- })
-
- // 修改密码
- ipcMain.handle('auth:changePassword', async (_event, oldPassword: string, newPassword: string) => {
- if (!configService) return { success: false, error: '配置服务未初始化' }
- return configService.changePassword(oldPassword, newPassword)
- })
-
- // 设置 Hello Secret
- ipcMain.handle('auth:setHelloSecret', async (_event, password: string) => {
- if (!configService) return { success: false }
- configService.setHelloSecret(password)
- return { success: true }
- })
-
- // 清除 Hello Secret
- ipcMain.handle('auth:clearHelloSecret', async () => {
- if (!configService) return { success: false }
- configService.clearHelloSecret()
- return { success: true }
- })
-
- // 检查是否处于 lock: 模式
- ipcMain.handle('auth:isLockMode', async () => {
- return configService?.isLockMode() ?? false
- })
-
- // 导出相关
- ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => {
- return exportService.getExportStats(sessionIds, options)
- })
-
- ipcMain.handle('export:pauseTask', async (_, taskId: string) => {
- const normalizedTaskId = normalizeExportTaskId(taskId)
- if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
- const success = exportTaskControlService.pauseTask(normalizedTaskId)
- if (success) postExportWorkerControl(normalizedTaskId, 'pause')
- return { success }
- })
-
- ipcMain.handle('export:resumeTask', async (_, taskId: string) => {
- const normalizedTaskId = normalizeExportTaskId(taskId)
- if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
- const success = exportTaskControlService.resumeTask(normalizedTaskId)
- if (success) postExportWorkerControl(normalizedTaskId, 'resume')
- return { success }
- })
-
- ipcMain.handle('export:cancelTask', async (_, taskId: string) => {
- const normalizedTaskId = normalizeExportTaskId(taskId)
- if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
- const success = exportTaskControlService.cancelTask(normalizedTaskId)
- if (success) postExportWorkerControl(normalizedTaskId, 'cancel')
- if (success && !activeExportTasks.has(normalizedTaskId)) {
- const cleanup = await exportTaskControlService.cleanupTask(normalizedTaskId)
- return cleanup.success
- ? { success: true, cleanup }
- : { success: false, error: cleanup.error || '清理已导出文件失败' }
- }
- return { success }
- })
-
- ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
- const taskId = normalizeExportTaskId(controlOptions?.taskId)
- if (taskId) exportTaskControlService.createControl(taskId, outputDir)
- if (taskId) activeExportTasks.add(taskId)
- const PROGRESS_FORWARD_INTERVAL_MS = 180
- let pendingProgress: ExportProgress | null = null
- let progressTimer: NodeJS.Timeout | null = null
- let lastProgressSentAt = 0
-
- const flushProgress = () => {
- if (!pendingProgress) return
- if (progressTimer) {
- clearTimeout(progressTimer)
- progressTimer = null
- }
- if (!event.sender.isDestroyed()) {
- event.sender.send('export:progress', pendingProgress)
- }
- pendingProgress = null
- lastProgressSentAt = Date.now()
- }
-
- const queueProgress = (progress: ExportProgress) => {
- pendingProgress = progress
- const force = progress.phase === 'complete'
- if (force) {
- flushProgress()
- return
- }
-
- const now = Date.now()
- const elapsed = now - lastProgressSentAt
- if (elapsed >= PROGRESS_FORWARD_INTERVAL_MS) {
- flushProgress()
- return
- }
-
- if (progressTimer) return
- progressTimer = setTimeout(() => {
- flushProgress()
- }, PROGRESS_FORWARD_INTERVAL_MS - elapsed)
- }
-
- const onProgress = (progress: ExportProgress) => {
- queueProgress(progress)
- }
-
- const cfg = configService || new ConfigService()
- configService = cfg
- const logEnabled = cfg.get('logEnabled')
- const dbPath = String(cfg.get('dbPath') || '').trim()
- const decryptKey = String(cfg.get('decryptKey') || '').trim()
- const myWxid = String(cfg.getMyWxidCleaned() || '').trim()
- const imageKeys = cfg.getImageKeysForCurrentWxid()
- const resourcesPath = app.isPackaged
- ? join(process.resourcesPath, 'resources')
- : join(app.getAppPath(), 'resources')
- const userDataPath = app.getPath('userData')
- const workerPath = join(__dirname, 'exportWorker.js')
-
- const runWorker = async () => {
- return await new Promise((resolve, reject) => {
- const worker = new Worker(workerPath, {
- workerData: {
- sessionIds,
- outputDir,
- options,
- taskId,
- dbPath,
- decryptKey,
- myWxid,
- imageXorKey: imageKeys.xorKey,
- imageAesKey: imageKeys.aesKey,
- resourcesPath,
- userDataPath,
- logEnabled,
- isPackaged: app.isPackaged
- }
- })
-
- let settled = false
- if (taskId) {
- activeExportWorkers.set(taskId, worker)
- }
- const finalizeResolve = (value: any) => {
- if (settled) return
- settled = true
- if (taskId && activeExportWorkers.get(taskId) === worker) {
- activeExportWorkers.delete(taskId)
- }
- worker.removeAllListeners()
- void worker.terminate()
- resolve(value)
- }
- const finalizeReject = (error: Error) => {
- if (settled) return
- settled = true
- if (taskId && activeExportWorkers.get(taskId) === worker) {
- activeExportWorkers.delete(taskId)
- }
- worker.removeAllListeners()
- void worker.terminate()
- reject(error)
- }
-
- worker.on('message', (msg: any) => {
- if (msg && msg.type === 'export:progress') {
- onProgress(msg.data as ExportProgress)
- return
- }
- if (msg && msg.type === 'export:createdFiles' && taskId) {
- const filePaths = Array.isArray(msg.filePaths) ? msg.filePaths : []
- for (const filePath of filePaths) {
- exportTaskControlService.recordCreatedFile(taskId, String(filePath || ''))
- }
- return
- }
- if (msg && msg.type === 'export:createdDirs' && taskId) {
- const dirPaths = Array.isArray(msg.dirPaths) ? msg.dirPaths : []
- for (const dirPath of dirPaths) {
- exportTaskControlService.recordCreatedDir(taskId, String(dirPath || ''))
- }
- return
- }
- if (msg && msg.type === 'export:createdFile' && taskId) {
- exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
- return
- }
- if (msg && msg.type === 'export:createdDir' && taskId) {
- exportTaskControlService.recordCreatedDir(taskId, String(msg.dirPath || ''))
- return
- }
- if (msg && msg.type === 'export:result') {
- finalizeResolve(msg.data)
- return
- }
- if (msg && msg.type === 'export:error') {
- finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败')))
- }
- })
-
- worker.on('error', (error) => {
- finalizeReject(error instanceof Error ? error : new Error(String(error)))
- })
-
- worker.on('exit', (code) => {
- if (settled) return
- if (code === 0) {
- finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' })
- } else {
- finalizeReject(new Error(`导出 Worker 异常退出: ${code}`))
- }
- })
- })
- }
-
- try {
- const result = await runWorker()
- return await finalizeExportTaskControlResult(taskId, result)
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error)
- console.error(`[export-worker] ${errorMessage}`)
- const normalizedSessionIds = Array.isArray(sessionIds) ? sessionIds : []
- const failedSessionErrors: Record = {}
- for (const sessionId of normalizedSessionIds) {
- failedSessionErrors[sessionId] = errorMessage
- }
- const result = {
- success: false,
- successCount: 0,
- failCount: normalizedSessionIds.length,
- failedSessionIds: normalizedSessionIds,
- failedSessionErrors,
- error: `导出 Worker 执行失败: ${errorMessage}`
- }
- return await finalizeExportTaskControlResult(taskId, result)
- } finally {
- if (taskId) activeExportTasks.delete(taskId)
- flushProgress()
- if (progressTimer) {
- clearTimeout(progressTimer)
- progressTimer = null
- }
- }
- })
-
- ipcMain.handle('export:exportSession', async (event, sessionId: string, outputPath: string, options: ExportOptions) => {
- const cfg = configService || new ConfigService()
- configService = cfg
- const imageKeys = cfg.getImageKeysForCurrentWxid()
- const workerPath = join(__dirname, 'exportWorker.js')
-
- try {
- return await new Promise((resolve) => {
- const worker = new Worker(workerPath, {
- workerData: {
- mode: 'single',
- sessionId,
- outputPath,
- options,
- dbPath: String(cfg.get('dbPath') || '').trim(),
- decryptKey: String(cfg.get('decryptKey') || '').trim(),
- myWxid: String(cfg.getMyWxidCleaned() || '').trim(),
- imageXorKey: imageKeys.xorKey,
- imageAesKey: imageKeys.aesKey,
- resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
- userDataPath: app.getPath('userData'),
- logEnabled: cfg.get('logEnabled'),
- isPackaged: app.isPackaged
- }
- })
-
- let settled = false
- const finalize = (value: any) => {
- if (settled) return
- settled = true
- worker.removeAllListeners()
- void worker.terminate()
- resolve(value)
- }
- const fail = (error: unknown) => {
- const errorMessage = error instanceof Error ? error.message : String(error)
- console.error(`[export-worker-single] ${errorMessage}`)
- finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
- }
-
- worker.on('message', (msg: any) => {
- if (msg && msg.type === 'export:progress') {
- if (!event.sender.isDestroyed()) {
- event.sender.send('export:progress', msg.data)
- }
- return
- }
- if (msg && msg.type === 'export:result') {
- finalize(msg.data)
- return
- }
- if (msg && msg.type === 'export:error') {
- fail(String(msg.error || '导出 Worker 执行失败'))
- }
- })
- worker.on('error', fail)
- worker.on('exit', (code) => {
- if (settled) return
- if (code === 0) {
- finalize({ success: false, error: '导出 Worker 未返回结果' })
- } else {
- fail(`导出 Worker 异常退出: ${code}`)
- }
- })
- })
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error)
- console.error(`[export-worker-single] ${errorMessage}`)
- return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
- }
- })
-
- ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
- const cfg = configService || new ConfigService()
- configService = cfg
- const workerPath = join(__dirname, 'exportWorker.js')
-
- try {
- return await new Promise((resolve) => {
- const worker = new Worker(workerPath, {
- workerData: {
- mode: 'contacts',
- outputDir,
- options,
- dbPath: String(cfg.get('dbPath') || '').trim(),
- decryptKey: String(cfg.get('decryptKey') || '').trim(),
- myWxid: String(cfg.getMyWxidCleaned() || '').trim(),
- resourcesPath: app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources'),
- userDataPath: app.getPath('userData'),
- logEnabled: cfg.get('logEnabled'),
- isPackaged: app.isPackaged
- }
- })
-
- let settled = false
- const finalize = (value: any) => {
- if (settled) return
- settled = true
- worker.removeAllListeners()
- void worker.terminate()
- resolve(value)
- }
- const fail = (error: unknown) => {
- const errorMessage = error instanceof Error ? error.message : String(error)
- console.error(`[export-worker-contacts] ${errorMessage}`)
- finalize({ success: false, error: `导出 Worker 执行失败: ${errorMessage}` })
- }
-
- worker.on('message', (msg: any) => {
- if (msg && msg.type === 'export:result') {
- finalize(msg.data)
- return
- }
- if (msg && msg.type === 'export:error') {
- fail(String(msg.error || '导出 Worker 执行失败'))
- }
- })
- worker.on('error', fail)
- worker.on('exit', (code) => {
- if (settled) return
- if (code === 0) {
- finalize({ success: false, error: '导出 Worker 未返回结果' })
- } else {
- fail(`导出 Worker 异常退出: ${code}`)
- }
- })
- })
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : String(error)
- console.error(`[export-worker-contacts] ${errorMessage}`)
- return { success: false, error: `导出 Worker 启动失败: ${errorMessage}` }
- }
- })
-
- // 数据分析相关
- ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
- return analyticsService.getOverallStatistics(force)
- })
-
- ipcMain.handle('analytics:getContactRankings', async (_, limit?: number, beginTimestamp?: number, endTimestamp?: number) => {
- return analyticsService.getContactRankings(limit, beginTimestamp, endTimestamp)
- })
-
- ipcMain.handle('analytics:getTimeDistribution', async () => {
- return analyticsService.getTimeDistribution()
- })
-
- ipcMain.handle('analytics:getSelfSentDailyDistribution', async (_, beginTimestamp?: number, endTimestamp?: number, force?: boolean) => {
- return analyticsService.getSelfSentDailyDistribution(beginTimestamp, endTimestamp, force)
- })
-
- ipcMain.handle('analytics:getExcludedUsernames', async () => {
- return analyticsService.getExcludedUsernames()
- })
-
- ipcMain.handle('analytics:setExcludedUsernames', async (_, usernames: string[]) => {
- return analyticsService.setExcludedUsernames(usernames)
- })
-
- ipcMain.handle('analytics:getExcludeCandidates', async () => {
- return analyticsService.getExcludeCandidates()
- })
-
- // 缓存管理
- ipcMain.handle('cache:clearAnalytics', async () => {
- return analyticsService.clearCache()
- })
-
- ipcMain.handle('cache:clearImages', async () => {
- const imageResult = await imageDecryptService.clearCache()
- const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
- snsService.clearMemoryCache()
- const errors = [imageResult, emojiResult]
- .filter((result) => !result.success)
- .map((result) => result.error)
- .filter(Boolean) as string[]
- if (errors.length > 0) {
- return { success: false, error: errors.join('; ') }
- }
- return { success: true }
- })
-
- ipcMain.handle('cache:clearAll', async () => {
- const [analyticsResult, imageResult] = await Promise.all([
- analyticsService.clearCache(),
- imageDecryptService.clearCache()
- ])
- const chatResult = chatService.clearCaches()
- snsService.clearMemoryCache()
- const errors = [analyticsResult, imageResult, chatResult]
- .filter((result) => !result.success)
- .map((result) => result.error)
- .filter(Boolean) as string[]
- if (errors.length > 0) {
- return { success: false, error: errors.join('; ') }
- }
- return { success: true }
- })
-
- ipcMain.handle('whisper:downloadModel', async (event) => {
- return voiceTranscribeService.downloadModel((progress) => {
- event.sender.send('whisper:downloadProgress', progress)
- })
- })
-
- ipcMain.handle('whisper:getModelStatus', async () => {
- return voiceTranscribeService.getModelStatus()
- })
-
- // 群聊分析相关
- ipcMain.handle('groupAnalytics:getGroupChats', async () => {
- return groupAnalyticsService.getGroupChats()
- })
-
- ipcMain.handle('groupAnalytics:getGroupMembers', async (_, chatroomId: string) => {
- return groupAnalyticsService.getGroupMembers(chatroomId)
- })
-
- ipcMain.handle(
- 'groupAnalytics:getGroupMembersPanelData',
- async (_, chatroomId: string, options?: { forceRefresh?: boolean; includeMessageCounts?: boolean } | boolean) => {
- const normalizedOptions = typeof options === 'boolean'
- ? { forceRefresh: options }
- : options
- return groupAnalyticsService.getGroupMembersPanelData(chatroomId, normalizedOptions)
- }
- )
-
- ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
- return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime)
- })
-
- ipcMain.handle('groupAnalytics:getGroupActiveHours', async (_, chatroomId: string, startTime?: number, endTime?: number) => {
- return groupAnalyticsService.getGroupActiveHours(chatroomId, startTime, endTime)
- })
-
- ipcMain.handle('groupAnalytics:getGroupMediaStats', async (_, chatroomId: string, startTime?: number, endTime?: number) => {
- return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
- })
-
- ipcMain.handle(
- 'groupAnalytics:getGroupMemberAnalytics',
- async (_, chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => {
- return groupAnalyticsService.getGroupMemberAnalytics(chatroomId, memberUsername, startTime, endTime)
- }
- )
-
- ipcMain.handle(
- 'groupAnalytics:getGroupMemberMessages',
- async (
- _,
- chatroomId: string,
- memberUsername: string,
- options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
- ) => {
- return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options)
- }
- )
-
- ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
- return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
- })
-
- ipcMain.handle(
- 'groupAnalytics:exportGroupMemberMessages',
- async (_, chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => {
- return groupAnalyticsService.exportGroupMemberMessages(chatroomId, memberUsername, outputPath, startTime, endTime)
- }
- )
-
- // 打开协议窗口
- ipcMain.handle('window:openAgreementWindow', async () => {
- createAgreementWindow()
- return true
- })
-
- // 打开图片查看窗口
- ipcMain.handle('window:openImageViewerWindow', async (_, imagePath: string, liveVideoPath?: string) => {
- // 如果是 dataUrl,写入临时文件
- if (imagePath.startsWith('data:')) {
- const commaIdx = imagePath.indexOf(',')
- const meta = imagePath.slice(5, commaIdx) // e.g. "image/jpeg;base64"
- const ext = meta.split('/')[1]?.split(';')[0] || 'jpg'
- const tmpPath = join(app.getPath('temp'), `weflow_preview_${Date.now()}.${ext}`)
- await writeFile(tmpPath, Buffer.from(imagePath.slice(commaIdx + 1), 'base64'))
- createImageViewerWindow(`file://${tmpPath.replace(/\\/g, '/')}`, liveVideoPath)
- } else {
- createImageViewerWindow(imagePath, liveVideoPath)
- }
- })
-
- // 完成引导,关闭引导窗口并显示主窗口
- ipcMain.handle('window:completeOnboarding', async () => {
- try {
- configService?.set('onboardingDone', true)
- } catch (e) {
- console.error('保存引导完成状态失败:', e)
- }
-
- if (onboardingWindow && !onboardingWindow.isDestroyed()) {
- onboardingWindow.close()
- }
- showMainWindow()
- return true
- })
-
- // 重新打开首次引导窗口,并隐藏主窗口
- ipcMain.handle('window:openOnboardingWindow', async (_, options?: { mode?: 'add-account' }) => {
- shouldShowMain = false
- if (mainWindow && !mainWindow.isDestroyed()) {
- mainWindow.hide()
- }
- const mode = options?.mode === 'add-account' ? 'add-account' : 'default'
- createOnboardingWindow(mode)
- return true
- })
-
- // 年度报告相关
- ipcMain.handle('annualReport:getAvailableYears', async () => {
- const cfg = configService || new ConfigService()
- configService = cfg
- return annualReportService.getAvailableYears({
- dbPath: cfg.get('dbPath'),
- decryptKey: cfg.get('decryptKey'),
- wxid: cfg.getMyWxidCleaned()
- })
- })
-
- ipcMain.handle('annualReport:startAvailableYearsLoad', async (event) => {
- const cfg = configService || new ConfigService()
- configService = cfg
-
- const dbPath = cfg.get('dbPath')
- const decryptKey = cfg.get('decryptKey')
- const wxid = cfg.get('myWxid')
- const cacheKey = buildAnnualReportYearsCacheKey(dbPath, wxid)
-
- const runningTaskId = annualReportYearsTaskByCacheKey.get(cacheKey)
- if (runningTaskId) {
- const runningTask = annualReportYearsLoadTasks.get(runningTaskId)
- if (runningTask && !runningTask.done) {
- return {
- success: true,
- taskId: runningTaskId,
- reused: true,
- snapshot: normalizeAnnualReportYearsSnapshot(runningTask.snapshot)
- }
- }
- annualReportYearsTaskByCacheKey.delete(cacheKey)
- }
-
- const cachedSnapshot = getAnnualReportYearsSnapshot(cacheKey)
- if (cachedSnapshot && cachedSnapshot.snapshot.done) {
- return {
- success: true,
- taskId: cachedSnapshot.taskId,
- reused: true,
- snapshot: normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot)
- }
- }
-
- const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
- const initialSnapshot: AnnualReportYearsProgressPayload = cachedSnapshot?.snapshot && !cachedSnapshot.snapshot.done
- ? {
- ...normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot),
- done: false,
- canceled: false,
- error: undefined
- }
- : {
- years: [],
- done: false,
- strategy: 'native',
- phase: 'native',
- statusText: '准备使用原生快速模式加载年份...',
- nativeElapsedMs: 0,
- scanElapsedMs: 0,
- totalElapsedMs: 0,
- switched: false,
- nativeTimedOut: false
- }
-
- const updateTaskSnapshot = (payload: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload | null => {
- const task = annualReportYearsLoadTasks.get(taskId)
- if (!task) return null
-
- const hasPayloadYears = Array.isArray(payload.years)
- const nextYears = (hasPayloadYears && (payload.done || (payload.years || []).length > 0))
- ? [...(payload.years || [])]
- : Array.isArray(task.snapshot.years) ? [...task.snapshot.years] : []
-
- const nextSnapshot: AnnualReportYearsProgressPayload = normalizeAnnualReportYearsSnapshot({
- ...task.snapshot,
- ...payload,
- years: nextYears
- })
- task.snapshot = nextSnapshot
- task.done = nextSnapshot.done === true
- task.updatedAt = Date.now()
- annualReportYearsLoadTasks.set(taskId, task)
- persistAnnualReportYearsSnapshot(task.cacheKey, taskId, nextSnapshot)
- return nextSnapshot
- }
-
- annualReportYearsLoadTasks.set(taskId, {
- cacheKey,
- canceled: false,
- done: false,
- snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot),
- updatedAt: Date.now()
- })
- annualReportYearsTaskByCacheKey.set(cacheKey, taskId)
- persistAnnualReportYearsSnapshot(cacheKey, taskId, initialSnapshot)
-
- void (async () => {
- try {
- const result = await annualReportService.getAvailableYears({
- dbPath,
- decryptKey,
- wxid,
- onProgress: (progress) => {
- if (isYearsLoadCanceled(taskId)) return
- const snapshot = updateTaskSnapshot({
- ...progress,
- done: false
- })
- if (!snapshot) return
- broadcastAnnualReportYearsProgress(taskId, snapshot)
- },
- shouldCancel: () => isYearsLoadCanceled(taskId)
- })
-
- const canceled = isYearsLoadCanceled(taskId)
- if (canceled) {
- const snapshot = updateTaskSnapshot({
- done: true,
- canceled: true,
- phase: 'done',
- statusText: '已取消年份加载'
- })
- if (snapshot) {
- broadcastAnnualReportYearsProgress(taskId, snapshot)
- }
- return
- }
-
- const completionPayload: AnnualReportYearsProgressPayload = result.success
- ? {
- years: result.data || [],
- done: true,
- strategy: result.meta?.strategy,
- phase: 'done',
- statusText: result.meta?.statusText || '年份数据加载完成',
- nativeElapsedMs: result.meta?.nativeElapsedMs,
- scanElapsedMs: result.meta?.scanElapsedMs,
- totalElapsedMs: result.meta?.totalElapsedMs,
- switched: result.meta?.switched,
- nativeTimedOut: result.meta?.nativeTimedOut
- }
- : {
- years: result.data || [],
- done: true,
- error: result.error || '加载年度数据失败',
- strategy: result.meta?.strategy,
- phase: 'done',
- statusText: result.meta?.statusText || '年份数据加载失败',
- nativeElapsedMs: result.meta?.nativeElapsedMs,
- scanElapsedMs: result.meta?.scanElapsedMs,
- totalElapsedMs: result.meta?.totalElapsedMs,
- switched: result.meta?.switched,
- nativeTimedOut: result.meta?.nativeTimedOut
- }
-
- const snapshot = updateTaskSnapshot(completionPayload)
- if (snapshot) {
- broadcastAnnualReportYearsProgress(taskId, snapshot)
- }
- } catch (e) {
- const snapshot = updateTaskSnapshot({
- done: true,
- error: String(e),
- phase: 'done',
- statusText: '年份数据加载失败',
- strategy: 'hybrid'
- })
- if (snapshot) {
- broadcastAnnualReportYearsProgress(taskId, snapshot)
- }
- } finally {
- const task = annualReportYearsLoadTasks.get(taskId)
- if (task) {
- annualReportYearsTaskByCacheKey.delete(task.cacheKey)
- }
- annualReportYearsLoadTasks.delete(taskId)
- }
- })()
-
- return {
- success: true,
- taskId,
- reused: false,
- snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot)
- }
- })
-
- ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => {
- const key = String(taskId || '').trim()
- if (!key) return { success: false, error: '任务ID不能为空' }
- const task = annualReportYearsLoadTasks.get(key)
- if (!task) return { success: true }
- task.canceled = true
- annualReportYearsLoadTasks.set(key, task)
- return { success: true }
- })
-
- ipcMain.handle('annualReport:generateReport', async (_, year: number) => {
- const cfg = configService || new ConfigService()
- configService = cfg
-
- const dbPath = cfg.get('dbPath')
- const decryptKey = cfg.get('decryptKey')
- const wxid = cfg.getMyWxidCleaned()
- const logEnabled = cfg.get('logEnabled')
-
- const resourcesPath = app.isPackaged
- ? join(process.resourcesPath, 'resources')
- : join(app.getAppPath(), 'resources')
- const userDataPath = app.getPath('userData')
-
- const workerPath = join(__dirname, 'annualReportWorker.js')
-
- return await new Promise((resolve) => {
- const worker = new Worker(workerPath, {
- workerData: { year, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled }
- })
-
- const cleanup = () => {
- worker.removeAllListeners()
- }
-
- worker.on('message', (msg: any) => {
- if (msg && msg.type === 'annualReport:progress') {
- for (const win of BrowserWindow.getAllWindows()) {
- if (!win.isDestroyed()) {
- win.webContents.send('annualReport:progress', msg.data)
- }
- }
- return
- }
- if (msg && (msg.type === 'annualReport:result' || msg.type === 'done')) {
- cleanup()
- void worker.terminate()
- resolve(msg.data ?? msg.result)
- return
- }
- if (msg && (msg.type === 'annualReport:error' || msg.type === 'error')) {
- cleanup()
- void worker.terminate()
- resolve({ success: false, error: msg.error || '年度报告生成失败' })
- }
- })
-
- worker.on('error', (err) => {
- cleanup()
- resolve({ success: false, error: String(err) })
- })
-
- worker.on('exit', (code) => {
- if (code !== 0) {
- cleanup()
- resolve({ success: false, error: `年度报告线程异常退出: ${code}` })
- }
- })
- })
- })
-
- ipcMain.handle('dualReport:generateReport', async (_, payload: { friendUsername: string; year: number }) => {
- const cfg = configService || new ConfigService()
- configService = cfg
-
- const dbPath = cfg.get('dbPath')
- const decryptKey = cfg.get('decryptKey')
- const wxid = cfg.getMyWxidCleaned()
- const logEnabled = cfg.get('logEnabled')
- const friendUsername = payload?.friendUsername
- const year = payload?.year ?? 0
- const excludeWords = cfg.get('wordCloudExcludeWords') || []
-
- if (!friendUsername) {
- return { success: false, error: '缺少好友用户名' }
- }
-
- const resourcesPath = app.isPackaged
- ? join(process.resourcesPath, 'resources')
- : join(app.getAppPath(), 'resources')
- const userDataPath = app.getPath('userData')
-
- const workerPath = join(__dirname, 'dualReportWorker.js')
-
- return await new Promise((resolve) => {
- const worker = new Worker(workerPath, {
- workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled, excludeWords }
- })
-
- const cleanup = () => {
- worker.removeAllListeners()
- }
-
- worker.on('message', (msg: any) => {
- if (msg && msg.type === 'dualReport:progress') {
- for (const win of BrowserWindow.getAllWindows()) {
- if (!win.isDestroyed()) {
- win.webContents.send('dualReport:progress', msg.data)
- }
- }
- return
- }
- if (msg && (msg.type === 'dualReport:result' || msg.type === 'done')) {
- cleanup()
- void worker.terminate()
- resolve(msg.data ?? msg.result)
- return
- }
- if (msg && (msg.type === 'dualReport:error' || msg.type === 'error')) {
- cleanup()
- void worker.terminate()
- resolve({ success: false, error: msg.error || '双人报告生成失败' })
- }
- })
-
- worker.on('error', (err) => {
- cleanup()
- resolve({ success: false, error: String(err) })
- })
-
- worker.on('exit', (code) => {
- if (code !== 0) {
- cleanup()
- resolve({ success: false, error: `双人报告线程异常退出: ${code}` })
- }
- })
- })
- })
-
- ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
- try {
- const { baseDir, folderName, images } = payload
- if (!baseDir || !folderName || !Array.isArray(images) || images.length === 0) {
- return { success: false, error: '导出参数无效' }
- }
-
- let targetDir = join(baseDir, folderName)
- if (existsSync(targetDir)) {
- let idx = 2
- while (existsSync(`${targetDir}_${idx}`)) idx++
- targetDir = `${targetDir}_${idx}`
- }
-
- await mkdir(targetDir, { recursive: true })
-
- for (const img of images) {
- const dataUrl = img.dataUrl || ''
- const commaIndex = dataUrl.indexOf(',')
- if (commaIndex <= 0) continue
- const base64 = dataUrl.slice(commaIndex + 1)
- const buffer = Buffer.from(base64, 'base64')
- const filePath = join(targetDir, img.name)
- await writeFile(filePath, buffer)
- }
-
- return { success: true, dir: targetDir }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- })
-
- ipcMain.handle('annualReport:captureCurrentWindow', async (event) => {
- try {
- const win = BrowserWindow.fromWebContents(event.sender)
- if (!win || win.isDestroyed()) {
- return { success: false, error: '窗口不可用' }
- }
-
- const image = await win.webContents.capturePage()
- return {
- success: true,
- dataUrl: image.toDataURL(),
- size: image.getSize()
- }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- })
-
- // 密钥获取
- ipcMain.handle('key:autoGetDbKey', async (event) => {
- return keyService.autoGetDbKey(180_000, (message: string, level: number) => {
- event.sender.send('key:dbKeyStatus', { message, level })
- })
- })
-
- ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => {
- return keyService.autoGetImageKey(manualDir, (message: string) => {
- event.sender.send('key:imageKeyStatus', { message })
- }, wxid)
- })
-
- ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => {
- return keyService.autoGetImageKeyByMemoryScan(userDir, (message: string) => {
- event.sender.send('key:imageKeyStatus', { message })
- })
- })
-
- // HTTP API 服务
- ipcMain.handle('http:start', async (_, port?: number, host?: string) => {
- const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1'
- return httpService.start(port || 5031, bindHost)
- })
-
- ipcMain.handle('http:stop', async () => {
- await httpService.stop()
- return { success: true }
- })
-
- ipcMain.handle('http:status', async () => {
- return {
- running: httpService.isRunning(),
- port: httpService.getPort(),
- mediaExportPath: httpService.getDefaultMediaExportPath()
- }
- })
-
- // 自动下载原图
- ipcMain.handle('image:startAutoDownload', async (_, whitelist?: string[]) => {
- return await imageDownloadService.startAutoDownload(whitelist || [])
- })
-
- ipcMain.handle('image:stopAutoDownload', async () => {
- await imageDownloadService.stopAutoDownload()
- return { success: true }
- })
-
- ipcMain.handle('image:getAutoDownloadStatus', async () => {
- return await imageDownloadService.getStatus()
- })
-}
-
-// 主窗口引用
-let mainWindow: BrowserWindow | null = null
-
-// 启动时自动检测更新
-function checkForUpdatesOnStartup() {
- if (!AUTO_UPDATE_ENABLED) return
- // 开发环境不检测更新
- if (process.env.VITE_DEV_SERVER_URL) return
-
- // 延迟3秒检测,等待窗口完全加载
- setTimeout(async () => {
- try {
- const result = await autoUpdater.checkForUpdates()
- if (result && result.updateInfo) {
- const currentVersion = app.getVersion()
- const latestVersion = result.updateInfo.version
-
- // 检查是否有新版本
- if (shouldOfferUpdateForTrack(latestVersion, currentVersion) && mainWindow) {
- // 检查该版本是否被用户忽略
- const ignoredVersion = configService?.get('ignoredUpdateVersion')
- if (ignoredVersion === latestVersion) {
-
- return
- }
-
- // 通知渲染进程有新版本
- mainWindow.webContents.send('app:updateAvailable', {
- version: latestVersion,
- releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes),
- minimumVersion: (result.updateInfo as any).minimumVersion
- })
- }
- }
- } catch (error) {
- console.error('启动时检查更新失败:', error)
- }
- }, 3000)
-}
-
-app.whenReady().then(async () => {
- // 先初始化配置,以便在启动早期判定是否需要静默启动
- configService = new ConfigService()
- applyAutoUpdateChannel('startup')
- syncLaunchAtStartupPreference()
- const onboardingDone = configService.get('onboardingDone') === true
- const startInBackground = onboardingDone && isSilentStartupEnabled()
- shouldShowMain = onboardingDone
-
- if (!startInBackground) {
- // 非静默模式下显示 Splash,提供启动反馈
- createSplashWindow()
-
- // 等待 Splash 页面加载完成后再推送进度
- if (splashWindow) {
- await new Promise((resolve) => {
- if (splashWindow!.webContents.isLoading()) {
- splashWindow!.webContents.once('did-finish-load', () => resolve())
- } else {
- resolve()
- }
- })
- splashWindow.webContents
- .executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
- .catch(() => {})
- }
- }
-
- const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
- const withTimeout = (task: () => Promise, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => {
- return new Promise((resolve) => {
- let settled = false
- const timer = setTimeout(() => {
- if (settled) return
- settled = true
- resolve({ timedOut: true, error: `timeout(${timeoutMs}ms)` })
- }, timeoutMs)
-
- task()
- .then((value) => {
- if (settled) return
- settled = true
- clearTimeout(timer)
- resolve({ timedOut: false, value })
- })
- .catch((error) => {
- if (settled) return
- settled = true
- clearTimeout(timer)
- resolve({ timedOut: false, error: String(error) })
- })
- })
- }
-
- updateSplashProgress(5, '正在加载配置...')
-
- // 将用户主题配置推送给 Splash 窗口
- if (splashWindow && !splashWindow.isDestroyed()) {
- const themeId = configService.get('themeId') || 'cloud-dancer'
- const themeMode = configService.get('theme') || 'system'
- splashWindow.webContents
- .executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`)
- .catch(() => {})
- }
- await delay(200)
-
- // 设置资源路径
- updateSplashProgress(12, '正在初始化...')
- const candidateResources = app.isPackaged
- ? join(process.resourcesPath, 'resources')
- : join(app.getAppPath(), 'resources')
- const fallbackResources = join(process.cwd(), 'resources')
- const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
- const userDataPath = app.getPath('userData')
- await delay(200)
-
- // 初始化数据库服务
- updateSplashProgress(20, '正在初始化...')
- wcdbService.setPaths(resourcesPath, userDataPath)
- wcdbService.setLogEnabled(configService.get('logEnabled') === true)
- await delay(200)
-
- // 注册 IPC 处理器
- updateSplashProgress(28, '正在初始化...')
- registerIpcHandlers()
- if (configService.get('autoDownloadHighRes')) {
- const whitelistArr = configService.get('autoDownloadWhitelist') || []
- const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
- ? (whitelistArr.join('\0') + '\0\0')
- : ''
- imageDownloadService.startAutoDownload(whitelistStr)
- }
- chatService.addDbMonitorListener((type, json) => {
- messagePushService.handleDbMonitorChange(type, json)
- insightService.handleDbMonitorChange(type, json)
- })
- messagePushService.start()
- insightService.start()
- groupSummaryService.start()
- await delay(200)
-
- // 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
- if (onboardingDone) {
- updateSplashProgress(34, '正在连接数据库...')
- const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
- const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
-
- if (!connected) {
- const reason = connectWarmup.timedOut
- ? connectWarmup.error
- : (connectWarmup.value?.error || connectWarmup.error || 'unknown')
- console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
- updateSplashProgress(68, '数据库预热已跳过')
- } else {
- const preloadUsernames = new Set()
-
- updateSplashProgress(44, '正在预加载会话...')
- const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
- if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
- for (const session of sessionsWarmup.value.sessions) {
- const username = String((session as any)?.username || '').trim()
- if (username) preloadUsernames.add(username)
- }
- }
-
- updateSplashProgress(56, '正在预加载联系人...')
- const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000)
- if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) {
- for (const contact of contactsWarmup.value.contacts) {
- const username = String((contact as any)?.username || '').trim()
- if (username) preloadUsernames.add(username)
- }
- }
-
- updateSplashProgress(63, '正在缓存联系人头像...')
- const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
- if (avatarWarmupUsernames.length > 0) {
- await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
- }
-
- updateSplashProgress(68, '正在初始化消息库索引...')
- await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000)
- }
- } else {
- updateSplashProgress(68, '首次启动准备中...')
- }
-
- // 创建主窗口(不显示,由启动流程统一控制)
- updateSplashProgress(70, '正在准备主窗口...')
- ensureWeChatRequestHeaderInterceptor()
- mainWindow = createWindow({ autoShow: false })
-
- const resolvedTrayIcon = resolveAppIconPath()
-
-
- try {
- tray = new Tray(resolvedTrayIcon)
- tray.setToolTip('WeFlow')
- const contextMenu = Menu.buildFromTemplate([
- {
- label: '显示主窗口',
- click: () => {
- if (mainWindow) {
- mainWindow.show()
- mainWindow.focus()
- }
- }
- },
- { type: 'separator' },
- {
- label: '退出',
- click: () => {
- isAppQuitting = true
- app.quit()
- }
- }
- ])
- tray.setContextMenu(contextMenu)
- tray.on('click', () => {
- if (mainWindow) {
- if (mainWindow.isVisible()) {
- mainWindow.focus()
- } else {
- mainWindow.show()
- mainWindow.focus()
- }
- }
- })
- tray.on('double-click', () => {
- if (mainWindow) {
- mainWindow.show()
- mainWindow.focus()
- }
- })
- } catch (e) {
- console.warn('[Tray] Failed to create tray icon:', e)
- }
-
- // 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
- updateSplashProgress(70, '正在准备主窗口...', true)
- await new Promise((resolve) => {
- if (mainWindowReady) {
- resolve()
- } else {
- mainWindow!.once('ready-to-show', () => {
- mainWindowReady = true
- resolve()
- })
- }
- })
-
- // 加载完成,收尾
- updateSplashProgress(100, '启动完成')
- await new Promise((resolve) => setTimeout(resolve, 250))
- closeSplash()
-
- if (!onboardingDone) {
- createOnboardingWindow()
- } else if (startInBackground && tray) {
- mainWindow?.hide()
- } else {
- mainWindow?.show()
- }
-
- // 启动时检测更新(不阻塞启动)
- checkForUpdatesOnStartup()
-
- await httpService.autoStart()
-
- app.on('activate', () => {
- if (mainWindow && !mainWindow.isDestroyed()) {
- if (!mainWindow.isVisible()) {
- mainWindow.show()
- }
- mainWindow.focus()
- return
- }
-
- if (BrowserWindow.getAllWindows().length === 0) {
- mainWindow = createWindow()
- }
- })
-})
-
-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()
- groupSummaryService.stop()
- // 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
- const forceExitTimer = setTimeout(() => {
- console.warn('[App] Force exit after timeout')
- app.exit(0)
- }, 5000)
- forceExitTimer.unref()
- try { await cloudControlService.stop() } catch {}
- // 停止自动下载服务
- try { await imageDownloadService.stopAutoDownload() } 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', () => {
- if (process.platform !== 'darwin') {
- app.quit()
- }
-})
diff --git a/electron/nodert.d.ts b/electron/nodert.d.ts
deleted file mode 100644
index 0c20623..0000000
--- a/electron/nodert.d.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-declare module '@nodert-win10-rs4/windows.security.credentials.ui' {
- export enum UserConsentVerificationResult {
- Verified = 0,
- DeviceNotPresent = 1,
- NotConfiguredForUser = 2,
- DisabledByPolicy = 3,
- DeviceBusy = 4,
- RetriesExhausted = 5,
- Canceled = 6
- }
-
- export enum UserConsentVerifierAvailability {
- Available = 0,
- DeviceNotPresent = 1,
- NotConfiguredForUser = 2,
- DisabledByPolicy = 3,
- DeviceBusy = 4
- }
-
- export class UserConsentVerifier {
- static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void;
- static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void;
- }
-}
diff --git a/electron/preload-env.ts b/electron/preload-env.ts
deleted file mode 100644
index 514b5e6..0000000
--- a/electron/preload-env.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { join, dirname } from 'path'
-
-/**
- * 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
- * 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
- */
-function enforceLocalDllPriority() {
- const isDev = !!process.env.VITE_DEV_SERVER_URL
- const sep = process.platform === 'win32' ? ';' : ':'
-
- let possiblePaths: string[] = []
-
- if (isDev) {
- // 开发环境
- possiblePaths.push(join(process.cwd(), 'resources'))
- } else {
- // 生产环境
- possiblePaths.push(dirname(process.execPath))
- if (process.resourcesPath) {
- possiblePaths.push(process.resourcesPath)
- }
- }
-
- const dllPaths = possiblePaths.join(sep)
-
- if (process.env.PATH) {
- process.env.PATH = dllPaths + sep + process.env.PATH
- } else {
- process.env.PATH = dllPaths
- }
-
-
-}
-
-try {
- enforceLocalDllPriority()
-} catch (e) {
- console.error('[WeFlow] Failed to enforce local service priority:', e)
-}
diff --git a/electron/preload.ts b/electron/preload.ts
deleted file mode 100644
index 98e97f3..0000000
--- a/electron/preload.ts
+++ /dev/null
@@ -1,654 +0,0 @@
-import { contextBridge, ipcRenderer } from 'electron'
-
-type CloseConfirmPayload = {
- canMinimizeToTray: boolean
- restoreMethod?: 'tray' | 'dock'
-}
-
-// 暴露给渲染进程的 API
-contextBridge.exposeInMainWorld('electronAPI', {
- // 配置
- config: {
- get: (key: string) => ipcRenderer.invoke('config:get', key),
- set: (key: string, value: any) => ipcRenderer.invoke('config:set', key, value),
- clear: () => ipcRenderer.invoke('config:clear')
- },
-
- // 通知
- notification: {
- show: (data: any) => ipcRenderer.invoke('notification:show', data),
- close: () => ipcRenderer.invoke('notification:close'),
- click: (payload: any) => ipcRenderer.send('notification-clicked', payload),
- ready: () => ipcRenderer.send('notification:ready'),
- resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
- onShow: (callback: (event: any, data: any) => void) => {
- ipcRenderer.on('notification:show', callback)
- return () => ipcRenderer.removeAllListeners('notification:show')
- }, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
- onNavigateToSession: (callback: (sessionId: string) => void) => {
- const listener = (_: any, sessionId: string) => callback(sessionId)
- ipcRenderer.on('navigate-to-session', listener)
- return () => ipcRenderer.removeListener('navigate-to-session', listener)
- },
- onNavigateToRoute: (callback: (route: string) => void) => {
- const listener = (_: any, route: string) => callback(route)
- ipcRenderer.on('navigate-to-route', listener)
- return () => ipcRenderer.removeListener('navigate-to-route', listener)
- }
- },
-
- // 认证
- auth: {
- hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
- verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
- unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
- enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
- disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
- changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
- setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
- clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
- isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
- },
-
-
- // 对话框
- dialog: {
- openFile: (options: any) => ipcRenderer.invoke('dialog:openFile', options),
- openDirectory: (options: any) => ipcRenderer.invoke('dialog:openDirectory', options),
- saveFile: (options: any) => ipcRenderer.invoke('dialog:saveFile', options)
- },
-
- // Shell
- shell: {
- openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path),
- openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url)
- },
-
- // App
- app: {
- getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
- getVersion: () => ipcRenderer.invoke('app:getVersion'),
- getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
- setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
- checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
- downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
- ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
- onDownloadProgress: (callback: (progress: any) => void) => {
- ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
- return () => ipcRenderer.removeAllListeners('app:downloadProgress')
- },
- onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
- ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
- return () => ipcRenderer.removeAllListeners('app:updateAvailable')
- },
- },
-
- // 日志
- log: {
- getPath: () => ipcRenderer.invoke('log:getPath'),
- read: () => ipcRenderer.invoke('log:read'),
- clear: () => ipcRenderer.invoke('log:clear'),
- debug: (data: any) => ipcRenderer.send('log:debug', data)
- },
-
- diagnostics: {
- getExportCardLogs: (options?: { limit?: number }) =>
- ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
- clearExportCardLogs: () =>
- ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
- exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
- ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
- },
-
- // 窗口控制
- window: {
- minimize: () => ipcRenderer.send('window:minimize'),
- maximize: () => ipcRenderer.send('window:maximize'),
- isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
- onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
- const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
- ipcRenderer.on('window:maximizeStateChanged', listener)
- return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
- },
- close: () => ipcRenderer.send('window:close'),
- onCloseConfirmRequested: (callback: (payload: CloseConfirmPayload) => void) => {
- const listener = (_: unknown, payload: CloseConfirmPayload) => callback(payload)
- ipcRenderer.on('window:confirmCloseRequested', listener)
- return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
- },
- respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') =>
- ipcRenderer.invoke('window:respondCloseConfirm', action),
- openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
- completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
- openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options),
- setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
- openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
- ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
- resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
- ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
- openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
- ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
- openChatHistoryWindow: (sessionId: string, messageId: number) =>
- ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
- openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) =>
- ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload),
- getChatHistoryPayload: (payloadId: string) =>
- ipcRenderer.invoke('window:getChatHistoryPayload', payloadId),
- openSessionChatWindow: (
- sessionId: string,
- options?: {
- source?: 'chat' | 'export'
- initialDisplayName?: string
- initialAvatarUrl?: string
- initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
- }
- ) =>
- ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
- },
-
- // 数据库路径
- dbPath: {
- autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
- scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
- scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
- getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
- },
-
- // WCDB 数据库
- wcdb: {
- testConnection: (dbPath: string, hexKey: string, wxid: string) =>
- ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
- open: (dbPath: string, hexKey: string, wxid: string) =>
- ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
- close: () => ipcRenderer.invoke('wcdb:close'),
-
- },
-
- backup: {
- create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload),
- inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload),
- restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload),
- onProgress: (callback: (progress: any) => void) => {
- const listener = (_: unknown, progress: any) => callback(progress)
- ipcRenderer.on('backup:progress', listener)
- return () => ipcRenderer.removeListener('backup:progress', listener)
- }
- },
-
- // 密钥获取
- key: {
- autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
- autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
- scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
- onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
- ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
- },
- onImageKeyStatus: (callback: (payload: { message: string }) => void) => {
- ipcRenderer.on('key:imageKeyStatus', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('key:imageKeyStatus')
- }
- },
-
-
- // 聊天
- chat: {
- connect: () => ipcRenderer.invoke('chat:connect'),
- getSessions: () => ipcRenderer.invoke('chat:getSessions'),
- markAllSessionsRead: () => ipcRenderer.invoke('chat:markAllSessionsRead'),
- getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
- getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
- getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
- getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
- getSessionMessageCounts: (sessionIds: string[], options?: { preferHintCache?: boolean; bypassSessionCache?: boolean }) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds, options),
- enrichSessionsContactInfo: (
- usernames: string[],
- options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
- ) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
- getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
- ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
- getLatestMessages: (sessionId: string, limit?: number) =>
- ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
- getNewMessages: (sessionId: string, minTime: number, limit?: number) =>
- ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
- getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
- getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
- updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) =>
- ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
- deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
- ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
- checkAntiRevokeTriggers: (sessionIds: string[]) =>
- ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
- installAntiRevokeTriggers: (sessionIds: string[]) =>
- ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
- uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
- ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
- resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
- ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
- getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
- downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
- getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
- clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
- ipcRenderer.invoke('chat:clearCurrentAccountData', options),
- close: () => ipcRenderer.invoke('chat:close'),
- getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
- getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
- getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
- getExportSessionStats: (
- sessionIds: string[],
- options?: {
- includeRelations?: boolean
- forceRefresh?: boolean
- allowStaleCache?: boolean
- preferAccurateSpecialTypes?: boolean
- cacheOnly?: boolean
- beginTimestamp?: number
- endTimestamp?: number
- }
- ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
- getGroupMyMessageCountHint: (chatroomId: string) =>
- ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
- getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
- getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
- ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
- getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
- getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
- getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
- getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
- getResourceMessages: (options?: {
- sessionId?: string
- types?: Array<'image' | 'video' | 'voice' | 'file'>
- beginTimestamp?: number
- endTimestamp?: number
- limit?: number
- offset?: number
- }) => ipcRenderer.invoke('chat:getResourceMessages', options),
- getMediaStream: (options?: {
- sessionId?: string
- mediaType?: 'image' | 'video' | 'all'
- beginTimestamp?: number
- endTimestamp?: number
- limit?: number
- offset?: number
- }) => ipcRenderer.invoke('chat:getMediaStream', options),
- resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
- getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
- onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
- const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
- ipcRenderer.on('chat:voiceTranscriptPartial', listener)
- return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
- },
- getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
- getMessage: (sessionId: string, localId: number) =>
- ipcRenderer.invoke('chat:getMessage', sessionId, localId),
- searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
- ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
- getMyFootprintStats: (
- beginTimestamp: number,
- endTimestamp: number,
- options?: {
- myWxid?: string
- privateSessionIds?: string[]
- groupSessionIds?: string[]
- mentionLimit?: number
- privateLimit?: number
- mentionMode?: 'text_at_me' | string
- }
- ) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options),
- exportMyFootprint: (
- beginTimestamp: number,
- endTimestamp: number,
- format: 'csv' | 'json',
- filePath: string
- ) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
- onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
- ipcRenderer.on('wcdb-change', callback)
- return () => ipcRenderer.removeListener('wcdb-change', callback)
- }
- },
-
-
-
- // 图片解密
- image: {
- 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; 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; 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)
- return () => ipcRenderer.removeListener('image:updateAvailable', listener)
- },
- onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
- const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
- ipcRenderer.on('image:cacheResolved', listener)
- return () => ipcRenderer.removeListener('image:cacheResolved', listener)
- },
- onDecryptProgress: (callback: (payload: {
- cacheKey: string
- imageMd5?: string
- imageDatName?: string
- stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
- progress: number
- status: 'running' | 'done' | 'error'
- message?: string
- }) => void) => {
- const listener = (_: unknown, payload: {
- cacheKey: string
- imageMd5?: string
- imageDatName?: string
- stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
- progress: number
- status: 'running' | 'done' | 'error'
- message?: string
- }) => callback(payload)
- ipcRenderer.on('image:decryptProgress', listener)
- return () => ipcRenderer.removeListener('image:decryptProgress', listener)
- },
- startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist),
- stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'),
- getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus')
- },
-
- // 视频
- video: {
- getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
- parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
- },
-
- process: {
- platform: process.platform,
- arch: process.arch
- },
-
- // 数据分析
- analytics: {
- getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
- getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
- ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
- getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
- getSelfSentDailyDistribution: (beginTimestamp?: number, endTimestamp?: number, force?: boolean) =>
- ipcRenderer.invoke('analytics:getSelfSentDailyDistribution', beginTimestamp, endTimestamp, force),
- getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
- setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
- getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
- onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
- ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('analytics:progress')
- }
- },
-
- // 缓存管理
- cache: {
- clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
- clearImages: () => ipcRenderer.invoke('cache:clearImages'),
- clearAll: () => ipcRenderer.invoke('cache:clearAll')
- },
-
- // 群聊分析
- groupAnalytics: {
- getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
- getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
- getGroupMembersPanelData: (
- chatroomId: string,
- options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
- ) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
- getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
- getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
- getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
- getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
- getGroupMemberMessages: (
- chatroomId: string,
- memberUsername: string,
- options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
- ) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
- exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
- exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
- ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
- },
-
- // 年度报告
- annualReport: {
- getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
- startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
- cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
- generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
- exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
- ipcRenderer.invoke('annualReport:exportImages', payload),
- captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
- onAvailableYearsProgress: (callback: (payload: {
- taskId: string
- years?: number[]
- done: boolean
- error?: string
- canceled?: boolean
- strategy?: 'cache' | 'native' | 'hybrid'
- phase?: 'cache' | 'native' | 'scan' | 'done'
- statusText?: string
- nativeElapsedMs?: number
- scanElapsedMs?: number
- totalElapsedMs?: number
- switched?: boolean
- nativeTimedOut?: boolean
- }) => void) => {
- ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
- },
- onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
- ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('annualReport:progress')
- }
- },
- dualReport: {
- generateReport: (payload: { friendUsername: string; year: number }) =>
- ipcRenderer.invoke('dualReport:generateReport', payload),
- onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
- ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('dualReport:progress')
- }
- },
-
- // 导出
- export: {
- getExportStats: (sessionIds: string[], options: any) =>
- ipcRenderer.invoke('export:getExportStats', sessionIds, options),
- exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) =>
- ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions),
- pauseTask: (taskId: string) =>
- ipcRenderer.invoke('export:pauseTask', taskId),
- resumeTask: (taskId: string) =>
- ipcRenderer.invoke('export:resumeTask', taskId),
- cancelTask: (taskId: string) =>
- ipcRenderer.invoke('export:cancelTask', taskId),
- exportSession: (sessionId: string, outputPath: string, options: any) =>
- ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
- exportContacts: (outputDir: string, options: any) =>
- ipcRenderer.invoke('export:exportContacts', outputDir, options),
- onProgress: (callback: (payload: {
- current: number
- total: number
- currentSession: string
- currentSessionId?: string
- phase: string
- phaseProgress?: number
- phaseTotal?: number
- phaseLabel?: string
- collectedMessages?: number
- exportedMessages?: number
- estimatedTotalMessages?: number
- writtenFiles?: number
- }) => void) => {
- ipcRenderer.on('export:progress', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('export:progress')
- }
- },
-
- whisper: {
- downloadModel: () =>
- ipcRenderer.invoke('whisper:downloadModel'),
- getModelStatus: () =>
- ipcRenderer.invoke('whisper:getModelStatus'),
- onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
- ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
- }
- },
-
- // 朋友圈
- sns: {
- getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
- ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
- getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
- getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
- getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
- getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
- getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
- debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
- proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
- downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
- exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
- onExportProgress: (callback: (payload: any) => void) => {
- ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
- return () => ipcRenderer.removeAllListeners('sns:exportProgress')
- },
- selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'),
- installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'),
- uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
- checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
- deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
- downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
- getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
- startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
- onCacheMigrationProgress: (callback: (payload: any) => void) => {
- const listener = (_event: unknown, payload: any) => callback(payload)
- ipcRenderer.on('sns:cacheMigrationProgress', listener)
- return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
- }
- },
-
- biz: {
- listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
- listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
- ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
- listPayRecords: (account?: string, limit?: number, offset?: number) =>
- ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
- },
-
-
- // 数据收集
- cloud: {
- init: () => ipcRenderer.invoke('cloud:init'),
- recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
- getLogs: () => ipcRenderer.invoke('cloud:getLogs')
- },
-
- // HTTP API 服务
- http: {
- start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
- stop: () => ipcRenderer.invoke('http:stop'),
- status: () => ipcRenderer.invoke('http:status')
- },
-
- // AI 见解
- insight: {
- testConnection: () => ipcRenderer.invoke('insight:testConnection'),
- getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
- listRecords: (filters?: any) => ipcRenderer.invoke('insight:listRecords', filters),
- getRecord: (id: string) => ipcRenderer.invoke('insight:getRecord', id),
- markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id),
- clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters),
- triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
- triggerSessionInsight: (payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- }) => ipcRenderer.invoke('insight:triggerSessionInsight', payload),
- listProfileStatuses: (sessionIds: string[]) => ipcRenderer.invoke('insight:listProfileStatuses', sessionIds),
- generateProfile: (payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- }) => ipcRenderer.invoke('insight:generateProfile', payload),
- cancelProfile: (sessionId?: string) => ipcRenderer.invoke('insight:cancelProfile', sessionId),
- generateFootprintInsight: (payload: {
- rangeLabel: string
- summary: {
- private_inbound_people?: number
- private_replied_people?: number
- private_outbound_people?: number
- private_reply_rate?: number
- mention_count?: number
- mention_group_count?: number
- }
- 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),
- generateMessageInsight: (payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- targetLocalId?: number
- targetCreateTime?: number
- targetMessageKey?: string
- targetText: string
- targetSenderName?: string
- contextCount?: number
- forceRefresh?: boolean
- }) => ipcRenderer.invoke('insight:generateMessageInsight', payload)
- },
-
- groupSummary: {
- listRecords: (filters?: any) => ipcRenderer.invoke('groupSummary:listRecords', filters),
- getRecord: (id: string) => ipcRenderer.invoke('groupSummary:getRecord', id),
- triggerManual: (payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- startTime: number
- endTime: number
- }) => ipcRenderer.invoke('groupSummary:triggerManual', payload),
- triggerDay: (payload: {
- sessionId: string
- displayName?: string
- avatarUrl?: string
- date: string
- }) => ipcRenderer.invoke('groupSummary:triggerDay', payload)
- },
-
- social: {
- saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
- validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
- }
-})
diff --git a/electron/services/accountDirResolver.ts b/electron/services/accountDirResolver.ts
deleted file mode 100644
index 7c440fb..0000000
--- a/electron/services/accountDirResolver.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * 账号目录解析器(Worker 线程 / 主进程通用)
- *
- * 职责:在 dbPath 根目录下,根据传入的 wxid,找出微信"实际写入数据"
- * 的那个账号子目录,例如:
- * dbPath = <微信数据根目录>
- * wxid = customwxid_abcd 或 customwxid
- * 期望返回 <微信数据根目录>/customwxid_abcd(带后缀、有 session.db 的那个)
- *
- * 与 ConfigService.getAccountDir 行为保持一致;二者实现独立是因为本文件
- * 也会在 Worker 线程中被加载,无法依赖 electron-store。
- */
-import { existsSync, readdirSync, statSync } from 'fs'
-import { join } from 'path'
-
-// 解析结果缓存(进程内,避免重复 IO)。key = `${dbPath}|${cleanedWxid}`
-const accountDirCache = new Map()
-
-/**
- * 把 wxid 字符串"标准化"为目录前缀。
- * - wxid_xxx_yyyy → wxid_xxx (wxid_ 后只取第一段)
- * - 自定义微信号_后缀(4 位) → 自定义微信号 (例如 customwxid_abcd → customwxid)
- * - 其他形式 → 原样返回
- *
- * 注意:清洗只是为了得到"前缀"用于扫描匹配,并不代表清洗结果就是真实目录名。
- * 真实目录名仍需在 dbPath 下按"前缀 + 任意后缀"扫描得出。
- */
-const cleanAccountDirName = (dirName: string): string => {
- const trimmed = dirName.trim()
- if (!trimmed) return trimmed
-
- if (trimmed.toLowerCase().startsWith('wxid_')) {
- const match = trimmed.match(/^(wxid_[^_]+)/i)
- if (match) return match[1]
- return trimmed
- }
-
- const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
- if (suffixMatch) return suffixMatch[1]
-
- return trimmed
-}
-
-const isDirectory = (path: string): boolean => {
- try {
- return statSync(path).isDirectory()
- } catch {
- return false
- }
-}
-
-/**
- * 解析账号目录的真实绝对路径。
- *
- * ## 修复 #996(错误码 -3001:未找到数据库目录)
- *
- * ### 旧实现存在的两处严重缺陷
- * 1. **对 wxid_ 开头的目录强制要求"带后缀"**:
- * 未自定义微信号的普通用户,目录就叫 `wxid_X`(无任何后缀),
- * 旧逻辑会因为段数不足而把它过滤掉,导致这类用户根本匹配不到。
- *
- * 2. **对非 wxid_ 开头(自定义微信号)走短路返回,且不校验目录有效性**:
- * 旧实现写法是
- * ```
- * if (!lowerWxid.startsWith('wxid_')) {
- * const direct = join(root, cleanedWxid)
- * if (existsSync(direct)) return direct // ← 直接返回,没校验里面有没有 db_storage
- * }
- * ```
- * 叠加 `cleanAccountDirName` 会把 `<自定义号>_<4位后缀>` 清洗成
- * `<自定义号>`,于是无论用户存的是哪个 wxid,都会命中旧的、无后缀的
- * 空目录(它真实存在但里面没有 db_storage),最终触发 -3001。
- *
- * ### 修复后的统一匹配流程
- * 1. 扫描 dbPath 下所有子目录;
- * 2. 同时接受**精确匹配**(`entry == cleanedWxid`) 与
- * **后缀匹配**(`entry.startsWith(cleanedWxid + '_')`) 两种命中方式;
- * 3. 用 {@link accountDirLooksValid} 过滤掉"看起来根本不像账号目录"的项
- * (没有 db_storage 也没有 FileStorage/Image[2]);
- * 4. 在剩余候选中按以下优先级排序,取最优:
- * - **有 session.db** > 没有:区分"真正写入数据"与"残留空目录";
- * - **后缀匹配** > 精确匹配:与微信 4.x 实际写入目录的命名习惯一致;
- * - **修改时间更新** > 更旧:兜底。
- */
-export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => {
- if (!dbPath || !wxid) return null
-
- const cleanedWxid = cleanAccountDirName(wxid)
- const normalized = dbPath.replace(/[\\/]+$/, '')
- const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
-
- // 命中缓存且目标仍存在则直接返回;目标已被删除的过期缓存项会被剔除
- const cached = accountDirCache.get(cacheKey)
- if (cached && existsSync(cached)) return cached
- if (cached && !existsSync(cached)) {
- accountDirCache.delete(cacheKey)
- }
-
- const lowerWxid = cleanedWxid.toLowerCase()
-
- try {
- const entries = readdirSync(normalized)
- type Candidate = { entryPath: string; isExact: boolean; hasSession: boolean; mtime: number }
- const candidates: Candidate[] = []
-
- for (const entry of entries) {
- const entryPath = join(normalized, entry)
- if (!isDirectory(entryPath)) continue
-
- const lowerEntry = entry.toLowerCase()
- const isExactMatch = lowerEntry === lowerWxid
- const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
- // 既不是精确命中、也不是前缀命中 → 与本 wxid 无关,跳过
- if (!isExactMatch && !isSuffixMatch) continue
-
- // 看起来不像账号目录(连 db_storage 与 FileStorage/Image 都没有)→ 跳过
- // 这一步是修复 #996 的关键:自定义微信号场景下旧的、无后缀空目录
- // 会在这里被过滤掉,避免后续 wcdbCore.open 误判为真实账号目录。
- if (!accountDirLooksValid(entryPath)) continue
-
- let mtime = 0
- try { mtime = statSync(entryPath).mtimeMs } catch { /* 忽略 stat 异常 */ }
- candidates.push({
- entryPath,
- isExact: isExactMatch,
- hasSession: accountDirHasSessionDb(entryPath),
- mtime,
- })
- }
-
- if (candidates.length > 0) {
- candidates.sort((a, b) => {
- // 1) 优先选有 session.db 的(真实写入数据的目录)
- if (a.hasSession !== b.hasSession) return a.hasSession ? -1 : 1
- // 2) 其次优先选"带后缀"的(更接近微信 4.x 实际写入目录)
- if (a.isExact !== b.isExact) return a.isExact ? 1 : -1
- // 3) 最后按修改时间倒序(最新的优先)
- return b.mtime - a.mtime
- })
- const best = candidates[0].entryPath
- accountDirCache.set(cacheKey, best)
- return best
- }
- } catch { /* 扫描目录失败时直接 fallthrough 返回 null */ }
-
- return null
-}
-
-/**
- * 浅层判定一个目录"看起来像不像账号目录":
- * 存在 db_storage 子目录,或存在 FileStorage/Image[2] 子目录之一即认为是。
- *
- * 用于在候选阶段剔除"同名但实际无数据"的残留空目录
- *(例如自定义微信号后遗留下来的旧 wxid 主目录)。
- */
-const accountDirLooksValid = (entryPath: string): boolean => {
- return (
- existsSync(join(entryPath, 'db_storage')) ||
- existsSync(join(entryPath, 'FileStorage', 'Image')) ||
- existsSync(join(entryPath, 'FileStorage', 'Image2'))
- )
-}
-
-/**
- * 检测账号目录下是否存在 session.db。
- *
- * 是排序优先级里"区分真实写入数据 vs 仅有空 db_storage 骨架"的关键判据,
- * 同时兼容微信 4.x 两种已知布局:
- * - db_storage/session/session.db (新版本嵌套布局)
- * - db_storage/session.db (部分版本扁平布局)
- */
-const accountDirHasSessionDb = (entryPath: string): boolean => {
- const candidates = [
- join(entryPath, 'db_storage', 'session', 'session.db'),
- join(entryPath, 'db_storage', 'session.db'),
- ]
- for (const candidate of candidates) {
- if (existsSync(candidate)) return true
- }
- return false
-}
diff --git a/electron/services/analyticsService.ts b/electron/services/analyticsService.ts
deleted file mode 100644
index 9cffe56..0000000
--- a/electron/services/analyticsService.ts
+++ /dev/null
@@ -1,833 +0,0 @@
-import { ConfigService } from './config'
-import { wcdbService } from './wcdbService'
-import { join } from 'path'
-import { readFile, writeFile, rm } from 'fs/promises'
-import { app } from 'electron'
-import { createHash } from 'crypto'
-
-export interface ChatStatistics {
- totalMessages: number
- textMessages: number
- imageMessages: number
- voiceMessages: number
- videoMessages: number
- emojiMessages: number
- otherMessages: number
- sentMessages: number
- receivedMessages: number
- firstMessageTime: number | null
- lastMessageTime: number | null
- activeDays: number
- messageTypeCounts: Record
-}
-
-export interface TimeDistribution {
- hourlyDistribution: Record
- weekdayDistribution: Record
- monthlyDistribution: Record
-}
-
-export interface SelfSentDailyDistribution {
- unit: 'day'
- dailyDistribution: Record
- totalMessages: number
- firstMessageTime: number | null
- lastMessageTime: number | null
- beginTimestamp: number
- endTimestamp: number
-}
-
-export interface ContactRanking {
- username: string
- displayName: string
- avatarUrl?: string
- wechatId?: string
- messageCount: number
- sentCount: number
- receivedCount: number
- lastMessageTime: number | null
-}
-
-class AnalyticsService {
- private configService: ConfigService
- private fallbackAggregateCache: { key: string; data: any; updatedAt: number } | null = null
- private aggregateCache: { key: string; data: any; updatedAt: number } | null = null
- private selfSentDailyCache: { key: string; data: SelfSentDailyDistribution; updatedAt: number } | null = null
- private aggregatePromise: { key: string; promise: Promise<{ success: boolean; data?: any; source?: string; error?: string }> } | null = null
-
- constructor() {
- this.configService = new ConfigService()
- }
-
- private normalizeUsername(username: string): string {
- return username.trim().toLowerCase()
- }
-
- private normalizeExcludedUsernames(value: unknown): string[] {
- if (!Array.isArray(value)) return []
- const normalized = value
- .map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '')
- .filter((item) => item.length > 0)
- return Array.from(new Set(normalized))
- }
-
- private getExcludedUsernamesList(): string[] {
- return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames'))
- }
-
- private getExcludedUsernamesSet(): Set {
- return new Set(this.getExcludedUsernamesList())
- }
-
- private async getAliasMap(usernames: string[]): Promise> {
- const map: Record = {}
- if (usernames.length === 0) return map
-
- const result = await wcdbService.getContactAliasMap(usernames)
- if (!result.success || !result.map) return map
- for (const [username, alias] of Object.entries(result.map)) {
- if (username && alias) map[username] = alias
- }
-
- return map
- }
-
- private cleanAccountDirName(name: string): string {
- const trimmed = name.trim()
- if (!trimmed) return trimmed
- if (trimmed.toLowerCase().startsWith('wxid_')) {
- const match = trimmed.match(/^(wxid_[^_]+)/i)
- if (match) return match[1]
- return trimmed
- }
-
- const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
- const cleaned = suffixMatch ? suffixMatch[1] : trimmed
-
- return cleaned
- }
-
- private isPrivateSession(username: string, cleanedWxid: string): boolean {
- if (!username) return false
- if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
- if (username.includes('@chatroom')) return false
- if (username === 'filehelper') return false
- if (username.startsWith('gh_')) return false
-
- if (username.toLowerCase() === 'weixin') return false
-
- const excludeList = [
- 'qqmail', 'fmessage', 'medianote', 'floatbottle',
- 'newsapp', 'brandsessionholder', 'brandservicesessionholder',
- 'notifymessage', 'opencustomerservicemsg', 'notification_messages',
- 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
- '@helper_folders', '@placeholder_foldgroup'
- ]
-
- for (const prefix of excludeList) {
- if (username.startsWith(prefix) || username === prefix) return false
- }
-
- if (username.includes('@kefu.openim') || username.includes('@openim')) return false
- if (username.includes('service_')) return false
-
- return true
- }
-
- private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
- const wxid = this.configService.get('myWxid')
- const dbPath = this.configService.get('dbPath')
- const decryptKey = this.configService.get('decryptKey')
-
- if (!wxid) return { success: false, error: '未配置微信ID' }
- if (!dbPath) return { success: false, error: '未配置数据库路径' }
- if (!decryptKey) return { success: false, error: '未配置解密密钥' }
-
- const accountDir = this.configService.getAccountDir(dbPath, wxid)
- if (!accountDir) return { success: false, error: '未找到账号目录' }
-
- const ok = await wcdbService.open(accountDir, decryptKey)
- if (!ok) return { success: false, error: 'WCDB 打开失败' }
-
- const cleanedWxid = this.cleanAccountDirName(wxid)
-
- return { success: true, cleanedWxid }
- }
-
- private async getPrivateSessions(
- cleanedWxid: string,
- excludedUsernames?: Set
- ): Promise<{ usernames: string[]; numericIds: string[] }> {
- const sessionResult = await wcdbService.getSessions()
- if (!sessionResult.success || !sessionResult.sessions) {
- return { usernames: [], numericIds: [] }
- }
- const rows = sessionResult.sessions as Record[]
- const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
-
- const sample = rows[0]
- void sample
-
- const sessions = rows.map((row) => {
- const username = row.username || row.user_name || row.userName || ''
- const idValue =
- row.id ??
- row.session_id ??
- row.sessionId ??
- row.sid ??
- row.local_id ??
- row.user_id ??
- row.userId ??
- row.chatroom_id ??
- row.chatroomId ??
- null
- return { username, idValue }
- })
- const usernames = sessions.map((s) => s.username)
- const privateSessions = sessions.filter((s) => {
- if (!this.isPrivateSession(s.username, cleanedWxid)) return false
- if (excluded.size === 0) return true
- return !excluded.has(this.normalizeUsername(s.username))
- })
- const privateUsernames = privateSessions.map((s) => s.username)
- const numericIds = privateSessions
- .map((s) => s.idValue)
- .filter((id) => typeof id === 'number' || (typeof id === 'string' && /^\d+$/.test(id)))
- .map((id) => String(id))
- return { usernames: privateUsernames, numericIds }
- }
-
- private async iterateSessionMessages(
- sessionId: string,
- onRow: (row: Record) => void,
- beginTimestamp = 0,
- endTimestamp = 0,
- lite = false
- ): Promise {
- const cursorResult = lite
- ? await wcdbService.openMessageCursorLite(sessionId, 500, true, beginTimestamp, endTimestamp)
- : await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
- if (!cursorResult.success || !cursorResult.cursor) return
-
- try {
- let hasMore = true
- let batchCount = 0
- while (hasMore) {
- const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
- if (!batch.success || !batch.rows) break
- for (const row of batch.rows) {
- onRow(row)
- }
- hasMore = batch.hasMore === true
-
- // 每处理完一个批次,如果已经处理了较多数据,暂时让出执行权
- batchCount++
- if (batchCount % 10 === 0) {
- await new Promise(resolve => setImmediate(resolve))
- }
- }
- } finally {
- await wcdbService.closeMessageCursor(cursorResult.cursor)
- }
- }
-
- private getRowCreateTime(row: Record): number {
- const raw = row.create_time ?? row.createTime ?? row.create_time_ms ?? '0'
- const parsed = parseInt(String(raw), 10)
- if (!Number.isFinite(parsed) || parsed <= 0) return 0
- return parsed > 1e12 ? Math.floor(parsed / 1000) : parsed
- }
-
- private isRowSentByMe(row: Record, cleanedWxid: string): boolean {
- const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
- const normalized = String(isSendRaw).trim().toLowerCase()
- let isSend = isSendRaw === 1 || isSendRaw === true || normalized === '1' || normalized === 'true'
-
- if (isSendRaw === undefined || isSendRaw === null) {
- const senderUsername = row.sender_username || row.senderUsername || row.sender
- if (senderUsername && cleanedWxid) {
- const senderLower = String(senderUsername).toLowerCase()
- const myWxidLower = cleanedWxid.toLowerCase()
- isSend = senderLower === myWxidLower || senderLower.startsWith(`${myWxidLower}_`)
- }
- }
-
- return isSend
- }
-
- private formatDayKey(timestamp: number): string {
- const date = new Date(timestamp * 1000)
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
- }
-
- private sortDailyDistribution(daily: Record): Record {
- const sorted: Record = {}
- for (const key of Object.keys(daily).sort()) {
- sorted[key] = daily[key]
- }
- return sorted
- }
-
- private completeDailyDistribution(
- daily: Record,
- firstTimestamp: number,
- lastTimestamp: number
- ): Record {
- if (!firstTimestamp || !lastTimestamp || lastTimestamp < firstTimestamp) {
- return this.sortDailyDistribution(daily)
- }
-
- const start = new Date(firstTimestamp * 1000)
- const end = new Date(lastTimestamp * 1000)
- start.setHours(0, 0, 0, 0)
- end.setHours(0, 0, 0, 0)
-
- const roughDays = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
- if (roughDays <= 0 || roughDays > 5000) {
- return this.sortDailyDistribution(daily)
- }
-
- const completed: Record = {}
- const cursor = new Date(start)
- while (cursor.getTime() <= end.getTime()) {
- const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`
- completed[key] = daily[key] || 0
- cursor.setDate(cursor.getDate() + 1)
- }
-
- return completed
- }
-
- private setProgress(window: any, status: string, progress: number) {
- if (window && !window.isDestroyed()) {
- window.webContents.send('analytics:progress', { status, progress })
- }
- }
-
- private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
- if (sessionIds.length === 0) {
- return `${beginTimestamp}-${endTimestamp}-0-empty`
- }
- const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort()
- const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12)
- return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}`
- }
-
- private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise {
- const cleanedWxid = this.configService.getMyWxidCleaned() || ''
-
- const aggregate = {
- total: 0,
- sent: 0,
- received: 0,
- firstTime: 0,
- lastTime: 0,
- typeCounts: {} as Record,
- hourly: {} as Record,
- weekday: {} as Record,
- daily: {} as Record,
- sentDaily: {} as Record,
- monthly: {} as Record,
- sessions: {} as Record,
- idMap: {}
- }
-
- for (const sessionId of sessionIds) {
- const sessionStat = { total: 0, sent: 0, received: 0, lastTime: 0 }
- await this.iterateSessionMessages(sessionId, (row) => {
- const createTime = this.getRowCreateTime(row)
- if (!createTime) return
- if (beginTimestamp > 0 && createTime < beginTimestamp) return
- if (endTimestamp > 0 && createTime > endTimestamp) return
-
- const localType = parseInt(row.local_type || row.type || '1', 10)
- const isSend = this.isRowSentByMe(row, cleanedWxid)
-
- aggregate.total += 1
- sessionStat.total += 1
-
- aggregate.typeCounts[localType] = (aggregate.typeCounts[localType] || 0) + 1
-
- if (isSend) {
- aggregate.sent += 1
- sessionStat.sent += 1
- } else {
- aggregate.received += 1
- sessionStat.received += 1
- }
-
- if (aggregate.firstTime === 0 || createTime < aggregate.firstTime) {
- aggregate.firstTime = createTime
- }
- if (createTime > aggregate.lastTime) {
- aggregate.lastTime = createTime
- }
- if (createTime > sessionStat.lastTime) {
- sessionStat.lastTime = createTime
- }
-
- const date = new Date(createTime * 1000)
- const hour = date.getHours()
- const weekday = date.getDay()
- const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
- const dayKey = `${monthKey}-${String(date.getDate()).padStart(2, '0')}`
-
- aggregate.hourly[hour] = (aggregate.hourly[hour] || 0) + 1
- aggregate.weekday[weekday] = (aggregate.weekday[weekday] || 0) + 1
- aggregate.monthly[monthKey] = (aggregate.monthly[monthKey] || 0) + 1
- aggregate.daily[dayKey] = (aggregate.daily[dayKey] || 0) + 1
- if (isSend) {
- aggregate.sentDaily[dayKey] = (aggregate.sentDaily[dayKey] || 0) + 1
- }
- }, beginTimestamp, endTimestamp)
-
- if (sessionStat.total > 0) {
- aggregate.sessions[sessionId] = sessionStat
- }
- }
-
- return aggregate
- }
-
- private async computeSelfSentDailyDistribution(
- sessionIds: string[],
- cleanedWxid: string,
- beginTimestamp = 0,
- endTimestamp = 0
- ): Promise {
- const dailyDistribution: Record = {}
- let totalMessages = 0
- let firstMessageTime = 0
- let lastMessageTime = 0
-
- for (const sessionId of sessionIds) {
- await this.iterateSessionMessages(sessionId, (row) => {
- const createTime = this.getRowCreateTime(row)
- if (!createTime) return
- if (beginTimestamp > 0 && createTime < beginTimestamp) return
- if (endTimestamp > 0 && createTime > endTimestamp) return
- if (!this.isRowSentByMe(row, cleanedWxid)) return
-
- const dayKey = this.formatDayKey(createTime)
- dailyDistribution[dayKey] = (dailyDistribution[dayKey] || 0) + 1
- totalMessages += 1
-
- if (firstMessageTime === 0 || createTime < firstMessageTime) {
- firstMessageTime = createTime
- }
- if (createTime > lastMessageTime) {
- lastMessageTime = createTime
- }
- }, beginTimestamp, endTimestamp, true)
- }
-
- return {
- unit: 'day',
- dailyDistribution: this.completeDailyDistribution(dailyDistribution, firstMessageTime, lastMessageTime),
- totalMessages,
- firstMessageTime: firstMessageTime || null,
- lastMessageTime: lastMessageTime || null,
- beginTimestamp,
- endTimestamp
- }
- }
-
- private async getAggregateWithFallback(
- sessionIds: string[],
- beginTimestamp = 0,
- endTimestamp = 0,
- window?: any,
- force = false
- ): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
- const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
-
- if (force) {
- if (this.aggregateCache) this.aggregateCache = null
- if (this.fallbackAggregateCache) this.fallbackAggregateCache = null
- }
-
- if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) {
- if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
- return { success: true, data: this.aggregateCache.data, source: 'cache' }
- }
- }
-
- // 尝试从文件加载缓存
- if (!force) {
- const fileCache = await this.loadCacheFromFile()
- if (fileCache && fileCache.key === cacheKey) {
- this.aggregateCache = fileCache
- return { success: true, data: fileCache.data, source: 'file-cache' }
- }
- }
-
- if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
- return this.aggregatePromise.promise
- }
-
- const promise = (async () => {
- const result = await wcdbService.getAggregateStats(sessionIds, beginTimestamp, endTimestamp)
- if (result.success && result.data && result.data.total > 0) {
- this.aggregateCache = { key: cacheKey, data: result.data, updatedAt: Date.now() }
- return { success: true, data: result.data, source: 'dll' }
- }
-
- if (this.fallbackAggregateCache && this.fallbackAggregateCache.key === cacheKey) {
- if (Date.now() - this.fallbackAggregateCache.updatedAt < 5 * 60 * 1000) {
- return { success: true, data: this.fallbackAggregateCache.data, source: 'cursor-cache' }
- }
- }
-
- if (window) {
- this.setProgress(window, '原生聚合为0,使用游标统计...', 45)
- }
-
- const data = await this.computeAggregateByCursor(sessionIds, beginTimestamp, endTimestamp)
- this.fallbackAggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
- this.aggregateCache = { key: cacheKey, data, updatedAt: Date.now() }
- return { success: true, data, source: 'cursor' }
- })()
-
- this.aggregatePromise = { key: cacheKey, promise }
- try {
- const result = await promise
- // 如果计算成功,同时写入此文件缓存
- if (result.success && result.data && result.source !== 'cache') {
- this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() })
- }
- return result
- } finally {
- if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
- this.aggregatePromise = null
- }
- }
- }
-
- private getCacheFilePath(): string {
- return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
- }
-
- private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
- try {
- const raw = await readFile(this.getCacheFilePath(), 'utf-8')
- return JSON.parse(raw)
- } catch { return null }
- }
-
- private async saveCacheToFile(data: any) {
- try {
- await writeFile(this.getCacheFilePath(), JSON.stringify(data))
- } catch (e) {
- console.error('保存统计缓存失败:', e)
- }
- }
-
- private normalizeAggregateSessions(
- sessions: Record | undefined,
- idMap: Record | undefined
- ): Record {
- if (!sessions) return {}
- if (!idMap) return sessions
- const keys = Object.keys(sessions)
- if (keys.length === 0) return sessions
- const numericKeys = keys.every((k) => /^\d+$/.test(k))
- if (!numericKeys) return sessions
- const remapped: Record = {}
- for (const [id, stat] of Object.entries(sessions)) {
- const username = idMap[id] || id
- remapped[username] = stat
- }
- return remapped
- }
-
- private async logAggregateDiagnostics(sessionIds: string[]): Promise {
- const samples = sessionIds.slice(0, 5)
- const results = await Promise.all(samples.map(async (sessionId) => {
- const countResult = await wcdbService.getMessageCount(sessionId)
- return { sessionId, success: countResult.success, count: countResult.count, error: countResult.error }
- }))
- void results
- }
-
- async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> {
- try {
- return { success: true, data: this.getExcludedUsernamesList() }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> {
- try {
- const normalized = this.normalizeExcludedUsernames(usernames)
- this.configService.set('analyticsExcludedUsernames', normalized)
- await this.clearCache()
- return { success: true, data: normalized }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> {
- try {
- const conn = await this.ensureConnected()
- if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
-
- const excluded = this.getExcludedUsernamesSet()
- const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set())
-
- const usernames = new Set(sessionInfo.usernames)
- for (const name of excluded) usernames.add(name)
-
- if (usernames.size === 0) {
- return { success: true, data: [] }
- }
-
- const usernameList = Array.from(usernames)
- const [displayNames, avatarUrls, aliasMap] = await Promise.all([
- wcdbService.getDisplayNames(usernameList),
- wcdbService.getAvatarUrls(usernameList),
- this.getAliasMap(usernameList)
- ])
-
- const entries = usernameList.map((username) => {
- const displayName = displayNames.success && displayNames.map
- ? (displayNames.map[username] || username)
- : username
- const avatarUrl = avatarUrls.success && avatarUrls.map
- ? avatarUrls.map[username]
- : undefined
- const alias = aliasMap[username]
- const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
- return { username, displayName, avatarUrl, wechatId }
- })
-
- return { success: true, data: entries }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
- try {
- const conn = await this.ensureConnected()
- if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
-
- const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
- if (sessionInfo.usernames.length === 0) {
- return { success: false, error: '未找到消息会话' }
- }
-
- const { BrowserWindow } = require('electron')
- const win = BrowserWindow.getAllWindows()[0]
- this.setProgress(win, '正在执行原生数据聚合...', 30)
-
- const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force)
-
- if (!result.success || !result.data) {
- return { success: false, error: result.error || '聚合统计失败' }
- }
-
- this.setProgress(win, '同步分析结果...', 90)
- const d = result.data
- if (d.total === 0 && sessionInfo.usernames.length > 0) {
- await this.logAggregateDiagnostics(sessionInfo.usernames)
- }
-
- const textTypes = [1, 244813135921]
- let textMessages = 0
- for (const t of textTypes) textMessages += (d.typeCounts[t] || 0)
- const imageMessages = d.typeCounts[3] || 0
- const voiceMessages = d.typeCounts[34] || 0
- const videoMessages = d.typeCounts[43] || 0
- const emojiMessages = d.typeCounts[47] || 0
- const otherMessages = d.total - textMessages - imageMessages - voiceMessages - videoMessages - emojiMessages
-
- // 估算活跃天数(按月分布估算或从日期列表中提取,由于 C++ 只返回了月份映射,
- // 我们这里暂时返回月份数作为参考,或者如果需要精确天数,原生层需要返回 Set 大小)
- // 为了性能,我们先用月份数,或者后续再优化 C++ 返回 activeDays 计数。
- // 当前 C++ 逻辑中 gs.monthly.size() 就是活跃月份。
- const activeMonths = Object.keys(d.monthly).length
-
- return {
- success: true,
- data: {
- totalMessages: d.total,
- textMessages,
- imageMessages,
- voiceMessages,
- videoMessages,
- emojiMessages,
- otherMessages: Math.max(0, otherMessages),
- sentMessages: d.sent,
- receivedMessages: d.received,
- firstMessageTime: d.firstTime || null,
- lastMessageTime: d.lastTime || null,
- activeDays: activeMonths * 20, // 粗略估算,或改为返回活跃月份
- messageTypeCounts: d.typeCounts
- }
- }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async getContactRankings(
- limit: number = 20,
- beginTimestamp: number = 0,
- endTimestamp: number = 0
- ): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
- try {
- const conn = await this.ensureConnected()
- if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
-
- const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
- if (sessionInfo.usernames.length === 0) {
- return { success: false, error: '未找到消息会话' }
- }
-
- const result = await this.getAggregateWithFallback(sessionInfo.usernames, beginTimestamp, endTimestamp)
- if (!result.success || !result.data) {
- return { success: false, error: result.error || '聚合统计失败' }
- }
-
- const d = result.data
- const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
- const usernames = Object.keys(sessions)
- const [displayNames, avatarUrls, aliasMap] = await Promise.all([
- wcdbService.getDisplayNames(usernames),
- wcdbService.getAvatarUrls(usernames),
- this.getAliasMap(usernames)
- ])
-
- const rankings: ContactRanking[] = usernames
- .map((username) => {
- const stat = sessions[username]
- const displayName = displayNames.success && displayNames.map
- ? (displayNames.map[username] || username)
- : username
- const avatarUrl = avatarUrls.success && avatarUrls.map
- ? avatarUrls.map[username]
- : undefined
- const alias = aliasMap[username] || ''
- const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
- return {
- username,
- displayName,
- avatarUrl,
- wechatId,
- messageCount: stat.total,
- sentCount: stat.sent,
- receivedCount: stat.received,
- lastMessageTime: stat.lastTime || null
- }
- })
- .sort((a, b) => b.messageCount - a.messageCount)
- .slice(0, limit)
-
- return { success: true, data: rankings }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async getTimeDistribution(): Promise<{ success: boolean; data?: TimeDistribution; error?: string }> {
- try {
- const conn = await this.ensureConnected()
- if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
-
- const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
- if (sessionInfo.usernames.length === 0) {
- return { success: false, error: '未找到消息会话' }
- }
-
- const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
- if (!result.success || !result.data) {
- return { success: false, error: result.error || '聚合统计失败' }
- }
-
- const d = result.data
-
- // SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
- // 前端期望 1=周一...7=周日
- const weekdayDistribution: Record = {}
- for (const [w, count] of Object.entries(d.weekday)) {
- const sqliteW = parseInt(w, 10)
- const jsW = sqliteW === 0 ? 7 : sqliteW
- weekdayDistribution[jsW] = count as number
- }
-
- // 补全 24 小时
- const hourlyDistribution: Record = {}
- for (let i = 0; i < 24; i++) {
- hourlyDistribution[i] = d.hourly[i] || 0
- }
-
- return {
- success: true,
- data: {
- hourlyDistribution,
- weekdayDistribution,
- monthlyDistribution: d.monthly
- }
- }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async getSelfSentDailyDistribution(
- beginTimestamp: number = 0,
- endTimestamp: number = 0,
- force = false
- ): Promise<{ success: boolean; data?: SelfSentDailyDistribution; error?: string }> {
- try {
- const conn = await this.ensureConnected()
- if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
-
- const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid)
- if (sessionInfo.usernames.length === 0) {
- return { success: false, error: '未找到消息会话' }
- }
-
- const cacheKey = `self-sent-daily-${this.buildAggregateCacheKey(sessionInfo.usernames, beginTimestamp, endTimestamp)}`
- if (force) this.selfSentDailyCache = null
-
- if (!force && this.selfSentDailyCache && this.selfSentDailyCache.key === cacheKey) {
- if (Date.now() - this.selfSentDailyCache.updatedAt < 5 * 60 * 1000) {
- return { success: true, data: this.selfSentDailyCache.data }
- }
- }
-
- const data = await this.computeSelfSentDailyDistribution(
- sessionInfo.usernames,
- conn.cleanedWxid,
- beginTimestamp,
- endTimestamp
- )
- this.selfSentDailyCache = { key: cacheKey, data, updatedAt: Date.now() }
-
- return { success: true, data }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async clearCache(): Promise<{ success: boolean; error?: string }> {
- this.aggregateCache = null
- this.fallbackAggregateCache = null
- this.selfSentDailyCache = null
- this.aggregatePromise = null
- try {
- await rm(this.getCacheFilePath(), { force: true })
- return { success: true }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-}
-
-export const analyticsService = new AnalyticsService()
diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts
deleted file mode 100644
index 04faedf..0000000
--- a/electron/services/annualReportService.ts
+++ /dev/null
@@ -1,1606 +0,0 @@
-import { parentPort } from 'worker_threads'
-import { wcdbService } from './wcdbService'
-import { resolveAccountDir } from './accountDirResolver'
-
-export interface TopContact {
- username: string
- displayName: string
- avatarUrl?: string
- messageCount: number
- sentCount: number
- receivedCount: number
-}
-
-export interface MonthlyTopFriend {
- month: number
- displayName: string
- avatarUrl?: string
- messageCount: number
-}
-
-export interface ChatPeakDay {
- date: string
- messageCount: number
- topFriend?: string
- topFriendCount?: number
-}
-
-export interface ActivityHeatmap {
- data: number[][]
-}
-
-export interface AnnualReportData {
- year: number
- totalMessages: number
- totalFriends: number
- coreFriends: TopContact[]
- monthlyTopFriends: MonthlyTopFriend[]
- peakDay: ChatPeakDay | null
- longestStreak: {
- friendName: string
- days: number
- startDate: string
- endDate: string
- } | null
- activityHeatmap: ActivityHeatmap
- midnightKing: {
- displayName: string
- count: number
- percentage: number
- } | null
- selfAvatarUrl?: string
- mutualFriend: {
- displayName: string
- avatarUrl?: string
- sentCount: number
- receivedCount: number
- ratio: number
- } | null
- socialInitiative: {
- initiatedChats: number
- receivedChats: number
- initiativeRate: number
- topInitiatedFriend?: string
- topInitiatedCount?: number
- } | null
- responseSpeed: {
- avgResponseTime: number
- fastestFriend: string
- fastestTime: number
- } | null
- topPhrases: {
- phrase: string
- count: number
- }[]
- snsStats?: {
- totalPosts: number
- typeCounts?: Record
- topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
- topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
- }
- lostFriend: {
- username: string
- displayName: string
- avatarUrl?: string
- earlyCount: number
- lateCount: number
- periodDesc: string
- } | null
-}
-
-export interface AvailableYearsLoadProgress {
- years: number[]
- strategy: 'cache' | 'native' | 'hybrid'
- phase: 'cache' | 'native' | 'scan'
- statusText: string
- nativeElapsedMs: number
- scanElapsedMs: number
- totalElapsedMs: number
- switched?: boolean
- nativeTimedOut?: boolean
-}
-
-interface AvailableYearsLoadMeta {
- strategy: 'cache' | 'native' | 'hybrid'
- nativeElapsedMs: number
- scanElapsedMs: number
- totalElapsedMs: number
- switched: boolean
- nativeTimedOut: boolean
- statusText: string
-}
-
-class AnnualReportService {
- private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
- private readonly availableYearsScanConcurrency = 4
- private readonly availableYearsColumnCache = new Map()
- private readonly availableYearsCache = new Map()
-
- constructor() {
- }
-
- private broadcastProgress(status: string, progress: number) {
- if (parentPort) {
- parentPort.postMessage({
- type: 'annualReport:progress',
- data: { status, progress }
- })
- }
- }
-
- private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
- if (onProgress) {
- onProgress(status, progress)
- return
- }
- this.broadcastProgress(status, progress)
- }
-
- private cleanAccountDirName(dirName: string): string {
- const trimmed = dirName.trim()
- if (!trimmed) return trimmed
- if (trimmed.toLowerCase().startsWith('wxid_')) {
- const match = trimmed.match(/^(wxid_[^_]+)/i)
- if (match) return match[1]
- return trimmed
- }
- const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
- const cleaned = suffixMatch ? suffixMatch[1] : trimmed
-
- return cleaned
- }
-
- private async ensureConnectedWithConfig(
- dbPath: string,
- decryptKey: string,
- wxid: string
- ): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
- if (!wxid) return { success: false, error: '未配置微信ID' }
- if (!dbPath) return { success: false, error: '未配置数据库路径' }
- if (!decryptKey) return { success: false, error: '未配置解密密钥' }
-
- const accountDir = resolveAccountDir(dbPath, wxid)
- if (!accountDir) return { success: false, error: '未找到账号目录' }
-
- const ok = await wcdbService.open(accountDir, decryptKey)
- if (!ok) return { success: false, error: 'WCDB 打开失败' }
-
- const cleanedWxid = this.cleanAccountDirName(wxid)
- return { success: true, cleanedWxid, rawWxid: wxid }
- }
-
- private async getPrivateSessions(cleanedWxid: string): Promise {
- const sessionResult = await wcdbService.getSessions()
- if (!sessionResult.success || !sessionResult.sessions) return []
- const rows = sessionResult.sessions as Record[]
-
- const excludeList = [
- 'qqmail', 'fmessage', 'medianote', 'floatbottle',
- 'newsapp', 'brandsessionholder', 'brandservicesessionholder',
- 'notifymessage', 'opencustomerservicemsg', 'notification_messages',
- 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
- '@helper_folders', '@placeholder_foldgroup'
- ]
-
- return rows
- .map((row) => row.username || row.user_name || row.userName || '')
- .filter((username) => {
- if (!username) return false
- if (username.includes('@chatroom')) return false
- if (username === 'filehelper') return false
- if (username.startsWith('gh_')) return false
- if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
- if (username.toLowerCase() === 'weixin') return false
-
- for (const prefix of excludeList) {
- if (username.startsWith(prefix) || username === prefix) return false
- }
-
- if (username.includes('@kefu.openim') || username.includes('@openim')) return false
- if (username.includes('service_')) return false
-
- return true
- })
- }
-
- private async getEdgeMessageTime(sessionId: string, ascending: boolean): Promise {
- const cursor = await wcdbService.openMessageCursor(sessionId, 1, ascending, 0, 0)
- if (!cursor.success || !cursor.cursor) return null
- try {
- const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
- if (!batch.success || !batch.rows || batch.rows.length === 0) return null
- const ts = parseInt(batch.rows[0].create_time || '0', 10)
- return ts > 0 ? ts : null
- } finally {
- await wcdbService.closeMessageCursor(cursor.cursor)
- }
- }
-
- private quoteSqlIdentifier(identifier: string): string {
- return `"${String(identifier || '').replace(/"/g, '""')}"`
- }
-
- private toUnixTimestamp(value: any): number {
- const n = Number(value)
- if (!Number.isFinite(n) || n <= 0) return 0
- // 兼容毫秒级时间戳
- const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
- return seconds > 0 ? seconds : 0
- }
-
- private addYearsFromRange(years: Set, firstTs: number, lastTs: number): boolean {
- let changed = false
- const currentYear = new Date().getFullYear()
- const minTs = firstTs > 0 ? firstTs : lastTs
- const maxTs = lastTs > 0 ? lastTs : firstTs
- if (minTs <= 0 || maxTs <= 0) return changed
-
- const minYear = new Date(minTs * 1000).getFullYear()
- const maxYear = new Date(maxTs * 1000).getFullYear()
- for (let y = minYear; y <= maxYear; y++) {
- if (y >= 2010 && y <= currentYear && !years.has(y)) {
- years.add(y)
- changed = true
- }
- }
- return changed
- }
-
- private normalizeAvailableYears(years: Iterable): number[] {
- return Array.from(new Set(Array.from(years)))
- .filter((y) => Number.isFinite(y))
- .map((y) => Math.floor(y))
- .sort((a, b) => b - a)
- }
-
- private async forEachWithConcurrency(
- items: T[],
- concurrency: number,
- handler: (item: T, index: number) => Promise,
- shouldStop?: () => boolean
- ): Promise {
- if (!items.length) return
- const workerCount = Math.max(1, Math.min(concurrency, items.length))
- let nextIndex = 0
- const workers: Promise[] = []
-
- for (let i = 0; i < workerCount; i++) {
- workers.push((async () => {
- while (true) {
- if (shouldStop?.()) break
- const current = nextIndex
- nextIndex += 1
- if (current >= items.length) break
- await handler(items[current], current)
- }
- })())
- }
-
- await Promise.all(workers)
- }
-
- private async detectTimeColumn(dbPath: string, tableName: string): Promise {
- const cacheKey = `${dbPath}\u0001${tableName}`
- if (this.availableYearsColumnCache.has(cacheKey)) {
- const cached = this.availableYearsColumnCache.get(cacheKey) || ''
- return cached || null
- }
-
- const result = await wcdbService.getMessageTableColumns(dbPath, tableName)
- if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) {
- this.availableYearsColumnCache.set(cacheKey, '')
- return null
- }
-
- const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
- const columns = new Set()
- for (const columnName of result.columns) {
- const name = String(columnName || '').trim().toLowerCase()
- if (name) columns.add(name)
- }
-
- for (const candidate of candidates) {
- if (columns.has(candidate)) {
- this.availableYearsColumnCache.set(cacheKey, candidate)
- return candidate
- }
- }
-
- this.availableYearsColumnCache.set(cacheKey, '')
- return null
- }
-
- private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
- const cacheKey = `${dbPath}\u0001${tableName}`
- const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
- const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
- const tried = new Set()
-
- const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
- const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName)
- if (!result.success || !result.data) return null
- const row = result.data as Record
- const actualColumn = String(row.column || '').trim().toLowerCase()
- if (column && actualColumn && column.toLowerCase() !== actualColumn) return null
- const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
- const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
- return { first, last }
- }
-
- tried.add(initialColumn)
- const quick = await queryByColumn(initialColumn)
- if (quick) {
- if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
- return quick
- }
-
- const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
- if (!detectedColumn || tried.has(detectedColumn)) {
- return null
- }
-
- return queryByColumn(detectedColumn)
- }
-
- private async getAvailableYearsByTableScan(
- sessionIds: string[],
- options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
- ): Promise {
- const years = new Set()
- let lastEmittedSize = 0
-
- const emitIfChanged = (force = false) => {
- if (!options?.onProgress) return
- const next = this.normalizeAvailableYears(years)
- if (!force && next.length === lastEmittedSize) return
- options.onProgress(next)
- lastEmittedSize = next.length
- }
-
- const shouldCancel = () => options?.shouldCancel?.() === true
-
- await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
- if (shouldCancel()) return
- const tableStats = await wcdbService.getMessageTableStats(sessionId)
- if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
- return
- }
-
- for (const table of tableStats.tables as Record[]) {
- if (shouldCancel()) return
- const tableName = String(table.table_name || table.name || '').trim()
- const dbPath = String(table.db_path || table.dbPath || '').trim()
- if (!tableName || !dbPath) continue
-
- const range = await this.getTableTimeRange(dbPath, tableName)
- if (!range) continue
- const changed = this.addYearsFromRange(years, range.first, range.last)
- if (changed) emitIfChanged()
- }
- }, shouldCancel)
-
- emitIfChanged(true)
- return this.normalizeAvailableYears(years)
- }
-
- private async getAvailableYearsByEdgeScan(
- sessionIds: string[],
- options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
- ): Promise {
- const years = new Set()
- let lastEmittedSize = 0
- const shouldCancel = () => options?.shouldCancel?.() === true
-
- const emitIfChanged = (force = false) => {
- if (!options?.onProgress) return
- const next = this.normalizeAvailableYears(years)
- if (!force && next.length === lastEmittedSize) return
- options.onProgress(next)
- lastEmittedSize = next.length
- }
-
- for (const sessionId of sessionIds) {
- if (shouldCancel()) break
- const first = await this.getEdgeMessageTime(sessionId, true)
- const last = await this.getEdgeMessageTime(sessionId, false)
- const changed = this.addYearsFromRange(years, first || 0, last || 0)
- if (changed) emitIfChanged()
- }
- emitIfChanged(true)
- return this.normalizeAvailableYears(years)
- }
-
- private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
- return `${dbPath}\u0001${cleanedWxid}`
- }
-
- private getCachedAvailableYears(cacheKey: string): number[] | null {
- const cached = this.availableYearsCache.get(cacheKey)
- if (!cached) return null
- if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
- this.availableYearsCache.delete(cacheKey)
- return null
- }
- return [...cached.years]
- }
-
- private setCachedAvailableYears(cacheKey: string, years: number[]): void {
- const normalized = this.normalizeAvailableYears(years)
-
- this.availableYearsCache.set(cacheKey, {
- years: normalized,
- updatedAt: Date.now()
- })
-
- if (this.availableYearsCache.size > 8) {
- let oldestKey = ''
- let oldestTime = Number.POSITIVE_INFINITY
- for (const [key, val] of this.availableYearsCache) {
- if (val.updatedAt < oldestTime) {
- oldestTime = val.updatedAt
- oldestKey = key
- }
- }
- if (oldestKey) this.availableYearsCache.delete(oldestKey)
- }
- }
-
- private decodeMessageContent(messageContent: any, compressContent: any): string {
- let content = this.decodeMaybeCompressed(compressContent)
- if (!content || content.length === 0) {
- content = this.decodeMaybeCompressed(messageContent)
- }
- return content
- }
-
- private decodeMaybeCompressed(raw: any): string {
- if (!raw) return ''
- if (typeof raw === 'string') {
- if (raw.length === 0) return ''
- // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
- // 短字符串(如 "123456" 等纯数字)容易被误判为 hex
- if (raw.length > 16 && this.looksLikeHex(raw)) {
- const bytes = Buffer.from(raw, 'hex')
- if (bytes.length > 0) return this.decodeBinaryContent(bytes)
- }
- // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
- // 短字符串(如 "test", "home" 等)容易被误判为 base64
- if (raw.length > 16 && this.looksLikeBase64(raw)) {
- try {
- const bytes = Buffer.from(raw, 'base64')
- return this.decodeBinaryContent(bytes)
- } catch {
- return raw
- }
- }
- return raw
- }
- return ''
- }
-
- private decodeBinaryContent(data: Buffer): string {
- if (data.length === 0) return ''
- try {
- if (data.length >= 4) {
- const magic = data.readUInt32LE(0)
- if (magic === 0xFD2FB528) {
- const fzstd = require('fzstd')
- const decompressed = fzstd.decompress(data)
- return Buffer.from(decompressed).toString('utf-8')
- }
- }
- const decoded = data.toString('utf-8')
- const replacementCount = (decoded.match(/\uFFFD/g) || []).length
- if (replacementCount < decoded.length * 0.2) {
- return decoded.replace(/\uFFFD/g, '')
- }
- return data.toString('latin1')
- } catch {
- return ''
- }
- }
-
- private looksLikeHex(s: string): boolean {
- if (s.length % 2 !== 0) return false
- return /^[0-9a-fA-F]+$/.test(s)
- }
-
- private looksLikeBase64(s: string): boolean {
- if (s.length % 4 !== 0) return false
- return /^[A-Za-z0-9+/=]+$/.test(s)
- }
-
- private formatDateYmd(date: Date): string {
- const y = date.getFullYear()
- const m = String(date.getMonth() + 1).padStart(2, '0')
- const d = String(date.getDate()).padStart(2, '0')
- return `${y}-${m}-${d}`
- }
-
- private async computeLongestStreak(
- sessionIds: string[],
- beginTimestamp: number,
- endTimestamp: number,
- onProgress?: (status: string, progress: number) => void,
- progressStart: number = 0,
- progressEnd: number = 0
- ): Promise<{ sessionId: string; days: number; start: Date | null; end: Date | null }> {
- let bestSessionId = ''
- let bestDays = 0
- let bestStart: Date | null = null
- let bestEnd: Date | null = null
- let lastProgressAt = 0
- let lastProgressSent = progressStart
-
- const shouldReportProgress = onProgress && progressEnd > progressStart && sessionIds.length > 0
- let apiTimeMs = 0
- let jsTimeMs = 0
-
- for (let i = 0; i < sessionIds.length; i++) {
- const sessionId = sessionIds[i]
- const openStart = Date.now()
- const cursor = await wcdbService.openMessageCursorLite(sessionId, 2000, true, beginTimestamp, endTimestamp)
- apiTimeMs += Date.now() - openStart
- if (!cursor.success || !cursor.cursor) continue
-
- let lastDayIndex: number | null = null
- let currentStreak = 0
- let currentStart: Date | null = null
- let maxStreak = 0
- let maxStart: Date | null = null
- let maxEnd: Date | null = null
-
- try {
- let hasMore = true
- while (hasMore) {
- const fetchStart = Date.now()
- const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
- apiTimeMs += Date.now() - fetchStart
- if (!batch.success || !batch.rows) break
-
- const processStart = Date.now()
- for (const row of batch.rows) {
- const createTime = parseInt(row.create_time || '0', 10)
- if (!createTime) continue
-
- const dt = new Date(createTime * 1000)
- const dayDate = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate())
- const dayIndex = Math.floor(dayDate.getTime() / 86400000)
-
- if (lastDayIndex !== null && dayIndex === lastDayIndex) continue
-
- if (lastDayIndex !== null && dayIndex - lastDayIndex === 1) {
- currentStreak++
- } else {
- currentStreak = 1
- currentStart = dayDate
- }
-
- if (currentStreak > maxStreak) {
- maxStreak = currentStreak
- maxStart = currentStart
- maxEnd = dayDate
- }
-
- lastDayIndex = dayIndex
- }
- jsTimeMs += Date.now() - processStart
-
- hasMore = batch.hasMore === true
- await new Promise(resolve => setImmediate(resolve))
- }
- } finally {
- const closeStart = Date.now()
- await wcdbService.closeMessageCursor(cursor.cursor)
- apiTimeMs += Date.now() - closeStart
- }
-
- if (maxStreak > bestDays) {
- bestDays = maxStreak
- bestSessionId = sessionId
- bestStart = maxStart
- bestEnd = maxEnd
- }
-
- if (shouldReportProgress) {
- const now = Date.now()
- if (now - lastProgressAt > 250) {
- const ratio = Math.min(1, (i + 1) / sessionIds.length)
- const progress = Math.floor(progressStart + ratio * (progressEnd - progressStart))
- if (progress > lastProgressSent) {
- lastProgressSent = progress
- lastProgressAt = now
- const label = `${i + 1}/${sessionIds.length}`
- const timing = (apiTimeMs > 0 || jsTimeMs > 0)
- ? `, DB ${(apiTimeMs / 1000).toFixed(1)}s / JS ${(jsTimeMs / 1000).toFixed(1)}s`
- : ''
- onProgress?.(`计算连续聊天... (${label}${timing})`, progress)
- }
- }
- }
- }
-
- return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
- }
-
- async getAvailableYears(params: {
- dbPath: string
- decryptKey: string
- wxid: string
- onProgress?: (payload: AvailableYearsLoadProgress) => void
- shouldCancel?: () => boolean
- nativeTimeoutMs?: number
- }): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
- try {
- const isCancelled = () => params.shouldCancel?.() === true
- const totalStartedAt = Date.now()
- let nativeElapsedMs = 0
- let scanElapsedMs = 0
- let switched = false
- let nativeTimedOut = false
- let latestYears: number[] = []
-
- const emitProgress = (payload: {
- years?: number[]
- strategy: 'cache' | 'native' | 'hybrid'
- phase: 'cache' | 'native' | 'scan'
- statusText: string
- switched?: boolean
- nativeTimedOut?: boolean
- }) => {
- if (!params.onProgress) return
- if (Array.isArray(payload.years)) latestYears = payload.years
- params.onProgress({
- years: latestYears,
- strategy: payload.strategy,
- phase: payload.phase,
- statusText: payload.statusText,
- nativeElapsedMs,
- scanElapsedMs,
- totalElapsedMs: Date.now() - totalStartedAt,
- switched: payload.switched ?? switched,
- nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
- })
- }
-
- const buildMeta = (
- strategy: 'cache' | 'native' | 'hybrid',
- statusText: string
- ): AvailableYearsLoadMeta => ({
- strategy,
- nativeElapsedMs,
- scanElapsedMs,
- totalElapsedMs: Date.now() - totalStartedAt,
- switched,
- nativeTimedOut,
- statusText
- })
-
- const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
- if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
- if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
- const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
- const cached = this.getCachedAvailableYears(cacheKey)
- if (cached) {
- latestYears = cached
- emitProgress({
- years: cached,
- strategy: 'cache',
- phase: 'cache',
- statusText: '命中缓存,已快速加载年份数据'
- })
- return {
- success: true,
- data: cached,
- meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
- }
- }
-
- const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
- if (sessionIds.length === 0) {
- return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
- }
- if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
-
- const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
- const nativeStartedAt = Date.now()
- let nativeTicker: ReturnType | null = null
-
- emitProgress({
- strategy: 'native',
- phase: 'native',
- statusText: '正在使用原生快速模式加载年份...'
- })
- nativeTicker = setInterval(() => {
- nativeElapsedMs = Date.now() - nativeStartedAt
- emitProgress({
- strategy: 'native',
- phase: 'native',
- statusText: '正在使用原生快速模式加载年份...'
- })
- }, 120)
-
- const nativeRace = await Promise.race([
- wcdbService.getAvailableYears(sessionIds)
- .then((result) => ({ kind: 'result' as const, result }))
- .catch((error) => ({ kind: 'error' as const, error: String(error) })),
- new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
- ])
-
- if (nativeTicker) {
- clearInterval(nativeTicker)
- nativeTicker = null
- }
- nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
-
- if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
-
- if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
- const years = this.normalizeAvailableYears(nativeRace.result.data)
- latestYears = years
- this.setCachedAvailableYears(cacheKey, years)
- emitProgress({
- years,
- strategy: 'native',
- phase: 'native',
- statusText: '原生快速模式加载完成'
- })
- return {
- success: true,
- data: years,
- meta: buildMeta('native', '原生快速模式加载完成')
- }
- }
-
- switched = true
- nativeTimedOut = nativeRace.kind === 'timeout'
- emitProgress({
- strategy: 'hybrid',
- phase: 'native',
- statusText: nativeTimedOut
- ? '原生快速模式超时,已自动切换到扫表兼容模式...'
- : '原生快速模式不可用,已自动切换到扫表兼容模式...',
- switched: true,
- nativeTimedOut
- })
-
- const scanStartedAt = Date.now()
- let scanTicker: ReturnType | null = null
- scanTicker = setInterval(() => {
- scanElapsedMs = Date.now() - scanStartedAt
- emitProgress({
- strategy: 'hybrid',
- phase: 'scan',
- statusText: nativeTimedOut
- ? '原生已超时,正在使用扫表兼容模式加载年份...'
- : '正在使用扫表兼容模式加载年份...',
- switched: true,
- nativeTimedOut
- })
- }, 120)
-
- let years = await this.getAvailableYearsByTableScan(sessionIds, {
- onProgress: (items) => {
- latestYears = items
- scanElapsedMs = Date.now() - scanStartedAt
- emitProgress({
- years: items,
- strategy: 'hybrid',
- phase: 'scan',
- statusText: nativeTimedOut
- ? '原生已超时,正在使用扫表兼容模式加载年份...'
- : '正在使用扫表兼容模式加载年份...',
- switched: true,
- nativeTimedOut
- })
- },
- shouldCancel: params.shouldCancel
- })
-
- if (isCancelled()) {
- if (scanTicker) clearInterval(scanTicker)
- return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
- }
- if (years.length === 0) {
- years = await this.getAvailableYearsByEdgeScan(sessionIds, {
- onProgress: (items) => {
- latestYears = items
- scanElapsedMs = Date.now() - scanStartedAt
- emitProgress({
- years: items,
- strategy: 'hybrid',
- phase: 'scan',
- statusText: '扫表结果为空,正在执行游标兜底扫描...',
- switched: true,
- nativeTimedOut
- })
- },
- shouldCancel: params.shouldCancel
- })
- }
- if (scanTicker) {
- clearInterval(scanTicker)
- scanTicker = null
- }
- scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
-
- if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
-
- this.setCachedAvailableYears(cacheKey, years)
- latestYears = years
- emitProgress({
- years,
- strategy: 'hybrid',
- phase: 'scan',
- statusText: '扫表兼容模式加载完成',
- switched: true,
- nativeTimedOut
- })
- return {
- success: true,
- data: years,
- meta: buildMeta('hybrid', '扫表兼容模式加载完成')
- }
- } catch (e) {
- return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
- }
- }
-
- async generateReportWithConfig(params: {
- year: number
- wxid: string
- dbPath: string
- decryptKey: string
- onProgress?: (status: string, progress: number) => void
- }): Promise<{ success: boolean; data?: AnnualReportData; error?: string }> {
- try {
- const { year, wxid, dbPath, decryptKey, onProgress } = params
- this.reportProgress('正在连接数据库...', 5, onProgress)
- const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
- if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
-
- const cleanedWxid = conn.cleanedWxid
- const rawWxid = conn.rawWxid
- const sessionIds = await this.getPrivateSessions(cleanedWxid)
- if (sessionIds.length === 0) {
- return { success: false, error: '未找到消息会话' }
- }
-
- this.reportProgress('加载会话列表...', 15, onProgress)
-
- const isAllTime = year <= 0
- const reportYear = isAllTime ? 0 : year
- const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000)
- const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
-
- const now = new Date()
- // 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd)
- const actualStartTime = startTime
- const actualEndTime = endTime
-
- let totalMessages = 0
- const contactStats = new Map()
- const monthlyStats = new Map>()
- const dailyStats = new Map()
- const dailyContactStats = new Map>()
- const heatmapData: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0))
- const midnightStats = new Map()
- let longestStreakSessionId = ''
- let longestStreakDays = 0
- let longestStreakStart: Date | null = null
- let longestStreakEnd: Date | null = null
-
- const conversationStarts = new Map()
- const responseTimeStats = new Map()
- const phraseCount = new Map()
- const lastMessageTime = new Map()
-
- const CONVERSATION_GAP = 3600
-
- this.reportProgress('统计会话消息...', 20, onProgress)
- const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime)
- if (!result.success || !result.data) {
- return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
- }
-
- const d = result.data
- totalMessages = d.total
- this.reportProgress('汇总基础统计...', 25, onProgress)
-
- const totalMessagesForProgress = totalMessages > 0 ? totalMessages : sessionIds.length
- let processedMessages = 0
- let lastProgressSent = 0
- let lastProgressAt = 0
-
- // 填充基础统计
- for (const [sid, stat] of Object.entries(d.sessions)) {
- const s = stat as any
- contactStats.set(sid, { sent: s.sent, received: s.received })
-
- const mMap = new Map()
- for (const [m, c] of Object.entries(s.monthly || {})) {
- mMap.set(parseInt(m, 10), c as number)
- }
- monthlyStats.set(sid, mMap)
- }
-
- // 填充全局分布,并锁定峰值日期以减少逐日消息统计
- let peakDayKey = ''
- let peakDayCount = 0
- for (const [day, count] of Object.entries(d.daily)) {
- const c = count as number
- dailyStats.set(day, c)
- if (c > peakDayCount) {
- peakDayCount = c
- peakDayKey = day
- }
- }
-
- let useSqlExtras = false
- let responseStatsFromSql: Record | null = null
- let topPhrasesFromSql: { phrase: string; count: number }[] | null = null
- let streakComputedInLoop = false
-
- let peakDayBegin = 0
- let peakDayEnd = 0
- if (peakDayKey) {
- const start = new Date(`${peakDayKey}T00:00:00`).getTime()
- if (!Number.isNaN(start)) {
- peakDayBegin = Math.floor(start / 1000)
- peakDayEnd = peakDayBegin + 24 * 3600 - 1
- }
- }
-
- this.reportProgress('加载扩展统计...', 30, onProgress)
- const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
- if (extras.success && extras.data) {
- this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
- const extrasData = extras.data as any
- const heatmap = extrasData.heatmap as number[][] | undefined
- if (Array.isArray(heatmap) && heatmap.length === 7) {
- for (let w = 0; w < 7; w++) {
- if (Array.isArray(heatmap[w])) {
- for (let h = 0; h < 24; h++) {
- heatmapData[w][h] = heatmap[w][h] || 0
- }
- }
- }
- }
-
- this.reportProgress('加载扩展统计... (解析夜聊统计)', 33, onProgress)
- const midnight = extrasData.midnight as Record | undefined
- if (midnight) {
- for (const [sid, count] of Object.entries(midnight)) {
- midnightStats.set(sid, count as number)
- }
- }
-
- this.reportProgress('加载扩展统计... (解析对话发起)', 34, onProgress)
- const conversation = extrasData.conversation as Record | undefined
- if (conversation) {
- for (const [sid, stats] of Object.entries(conversation)) {
- conversationStarts.set(sid, { initiated: stats.initiated || 0, received: stats.received || 0 })
- }
- }
-
- this.reportProgress('加载扩展统计... (解析响应速度)', 35, onProgress)
- responseStatsFromSql = extrasData.response || null
-
- this.reportProgress('加载扩展统计... (解析峰值日)', 36, onProgress)
- const peakDayCounts = extrasData.peakDay as Record | undefined
- if (peakDayKey && peakDayCounts) {
- const dayMap = new Map()
- for (const [sid, count] of Object.entries(peakDayCounts)) {
- dayMap.set(sid, count as number)
- }
- if (dayMap.size > 0) {
- dailyContactStats.set(peakDayKey, dayMap)
- }
- }
-
- this.reportProgress('加载扩展统计... (解析常用语)', 37, onProgress)
- const sqlPhrases = extrasData.topPhrases as { phrase: string; count: number }[] | undefined
- if (Array.isArray(sqlPhrases) && sqlPhrases.length > 0) {
- topPhrasesFromSql = sqlPhrases
- }
-
- const streak = extrasData.streak as { sessionId?: string; days?: number; startDate?: string; endDate?: string } | undefined
- if (streak && streak.sessionId && streak.days && streak.days > 0) {
- longestStreakSessionId = streak.sessionId
- longestStreakDays = streak.days
- longestStreakStart = streak.startDate ? new Date(`${streak.startDate}T00:00:00`) : null
- longestStreakEnd = streak.endDate ? new Date(`${streak.endDate}T00:00:00`) : null
- if (longestStreakStart && !Number.isNaN(longestStreakStart.getTime()) &&
- longestStreakEnd && !Number.isNaN(longestStreakEnd.getTime())) {
- streakComputedInLoop = true
- }
- }
-
- useSqlExtras = true
- this.reportProgress('加载扩展统计... (完成)', 40, onProgress)
- } else if (!extras.success) {
- const reason = extras.error ? ` (${extras.error})` : ''
- this.reportProgress(`扩展统计失败,转入完整分析...${reason}`, 30, onProgress)
- }
-
- if (!useSqlExtras) {
- // 注意:原生层目前未返回交叉维度 heatmapData[weekday][hour],
- // 这里的 heatmapData 仍然需要通过下面的遍历来精确填充。
-
- // 考虑到 Annual Report 需要一些复杂的序列特征(响应速度、对话发起)和文本特征(常用语),
- // 我们仍然保留一次轻量级循环,但因为有了原生统计,我们可以分步进行,或者如果数据量极大则跳过某些步骤。
- // 为保持功能完整,我们进行深度集成的轻量遍历:
- for (let i = 0; i < sessionIds.length; i++) {
- const sessionId = sessionIds[i]
- const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime)
- if (!cursor.success || !cursor.cursor) continue
-
- let lastDayIndex: number | null = null
- let currentStreak = 0
- let currentStart: Date | null = null
- let maxStreak = 0
- let maxStart: Date | null = null
- let maxEnd: Date | null = null
-
- try {
- let hasMore = true
- while (hasMore) {
- const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
- if (!batch.success || !batch.rows) break
-
- for (const row of batch.rows) {
- const createTime = parseInt(row.create_time || '0', 10)
- if (!createTime) continue
-
- const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
- let isSent = parseInt(isSendRaw, 10) === 1
- const localType = parseInt(row.local_type || row.type || '1', 10)
-
- // 兼容逻辑
- if (isSendRaw === undefined || isSendRaw === null || isSendRaw === '0') {
- const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
- if (sender) {
- const rawLower = rawWxid.toLowerCase()
- const cleanedLower = cleanedWxid.toLowerCase()
- if (sender === rawLower || sender === cleanedLower ||
- rawLower.startsWith(sender + '_') || cleanedLower.startsWith(sender + '_')) {
- isSent = true
- }
- }
- }
-
- // 响应速度 & 对话发起
- if (!conversationStarts.has(sessionId)) {
- conversationStarts.set(sessionId, { initiated: 0, received: 0 })
- }
- const convStats = conversationStarts.get(sessionId)!
- const lastMsg = lastMessageTime.get(sessionId)
- if (!lastMsg || (createTime - lastMsg.time) > CONVERSATION_GAP) {
- if (isSent) convStats.initiated++
- else convStats.received++
- } else if (lastMsg.isSent !== isSent) {
- if (isSent && !lastMsg.isSent) {
- const responseTime = createTime - lastMsg.time
- if (responseTime > 0 && responseTime < 86400) {
- if (!responseTimeStats.has(sessionId)) responseTimeStats.set(sessionId, [])
- responseTimeStats.get(sessionId)!.push(responseTime)
- }
- }
- }
- lastMessageTime.set(sessionId, { time: createTime, isSent })
-
- // 常用语
- if ((localType === 1 || localType === 244813135921) && isSent) {
- const content = this.decodeMessageContent(row.message_content, row.compress_content)
- const text = String(content).trim()
- if (text.length >= 2 && text.length <= 20 &&
- !text.includes('http') && !text.includes('<') &&
- !text.startsWith('[') && !text.startsWith(' maxStreak) {
- maxStreak = currentStreak
- maxStart = currentStart
- maxEnd = dayDate
- }
- lastDayIndex = dayIndex
- }
-
- if (dt.getHours() >= 0 && dt.getHours() < 6) {
- midnightStats.set(sessionId, (midnightStats.get(sessionId) || 0) + 1)
- }
-
- if (peakDayKey) {
- const dayKey = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
- if (dayKey === peakDayKey) {
- if (!dailyContactStats.has(dayKey)) dailyContactStats.set(dayKey, new Map())
- const dayContactMap = dailyContactStats.get(dayKey)!
- dayContactMap.set(sessionId, (dayContactMap.get(sessionId) || 0) + 1)
- }
- }
-
- if (totalMessagesForProgress > 0) {
- processedMessages++
- }
- }
- hasMore = batch.hasMore === true
-
- const now = Date.now()
- if (now - lastProgressAt > 200) {
- let progress: number
- if (totalMessagesForProgress > 0) {
- const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
- progress = 30 + Math.floor(ratio * 50)
- } else {
- const ratio = Math.min(1, (i + 1) / sessionIds.length)
- progress = 30 + Math.floor(ratio * 50)
- }
- if (progress > lastProgressSent) {
- lastProgressSent = progress
- lastProgressAt = now
- let label = `${i + 1}/${sessionIds.length}`
- if (totalMessagesForProgress > 0) {
- const done = Math.min(processedMessages, totalMessagesForProgress)
- label = `${done}/${totalMessagesForProgress}`
- }
- this.reportProgress(`分析聊天记录... (${label})`, progress, onProgress)
- }
- }
- await new Promise(resolve => setImmediate(resolve))
- }
- } finally {
- await wcdbService.closeMessageCursor(cursor.cursor)
- }
-
- if (maxStreak > longestStreakDays) {
- longestStreakDays = maxStreak
- longestStreakSessionId = sessionId
- longestStreakStart = maxStart
- longestStreakEnd = maxEnd
- }
- }
- streakComputedInLoop = true
- }
-
- if (!streakComputedInLoop) {
- this.reportProgress('计算连续聊天...', 45, onProgress)
- const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75)
- if (streakResult.days > longestStreakDays) {
- longestStreakDays = streakResult.days
- longestStreakSessionId = streakResult.sessionId
- longestStreakStart = streakResult.start
- longestStreakEnd = streakResult.end
- }
- }
-
- // 获取朋友圈统计
- this.reportProgress('分析朋友圈数据...', 75, onProgress)
- let snsStatsResult: {
- totalPosts: number
- typeCounts?: Record
- topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
- topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
- } | undefined
-
- const snsBeginTime = isAllTime ? 0 : actualStartTime
- const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime
- const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime)
-
- if (snsStats.success && snsStats.data) {
- const d = snsStats.data
- const usersToFetch = new Set()
- d.topLikers?.forEach((u: any) => usersToFetch.add(u.username))
- d.topLiked?.forEach((u: any) => usersToFetch.add(u.username))
-
- const snsUserIds = Array.from(usersToFetch)
- const [snsDisplayNames, snsAvatarUrls] = await Promise.all([
- wcdbService.getDisplayNames(snsUserIds),
- wcdbService.getAvatarUrls(snsUserIds)
- ])
-
- const getSnsUserInfo = (username: string) => ({
- displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username,
- avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined
- })
-
- snsStatsResult = {
- totalPosts: d.totalPosts || 0,
- typeCounts: d.typeCounts,
- topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })),
- topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) }))
- }
- }
-
- // ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0,兜底使用导出统计总数。
- if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) {
- const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid)
- if (snsExportStats.success && snsExportStats.data) {
- const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0))
- snsStatsResult = {
- totalPosts: fallbackTotalPosts,
- typeCounts: snsStatsResult?.typeCounts,
- topLikers: snsStatsResult?.topLikers || [],
- topLiked: snsStatsResult?.topLiked || []
- }
- }
- }
-
- this.reportProgress('整理联系人信息...', 85, onProgress)
-
- const contactIds = Array.from(contactStats.keys())
- const [displayNames, avatarUrls] = await Promise.all([
- wcdbService.getDisplayNames(contactIds),
- wcdbService.getAvatarUrls(contactIds)
- ])
-
- const contactInfoMap = new Map()
- for (const sessionId of contactIds) {
- contactInfoMap.set(sessionId, {
- displayName: displayNames.success && displayNames.map ? (displayNames.map[sessionId] || sessionId) : sessionId,
- avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[sessionId] : undefined
- })
- }
-
- const selfAvatarResult = await wcdbService.getAvatarUrls([rawWxid, cleanedWxid])
- const selfAvatarUrl = selfAvatarResult.success && selfAvatarResult.map
- ? (selfAvatarResult.map[rawWxid] || selfAvatarResult.map[cleanedWxid])
- : undefined
-
- const coreFriends: TopContact[] = Array.from(contactStats.entries())
- .map(([sessionId, stats]) => {
- const info = contactInfoMap.get(sessionId)
- return {
- username: sessionId,
- displayName: info?.displayName || sessionId,
- avatarUrl: info?.avatarUrl,
- messageCount: stats.sent + stats.received,
- sentCount: stats.sent,
- receivedCount: stats.received
- }
- })
- .sort((a, b) => b.messageCount - a.messageCount)
- .slice(0, 3)
-
- const monthlyTopFriends: MonthlyTopFriend[] = []
- for (let month = 1; month <= 12; month++) {
- let maxCount = 0
- let topSessionId = ''
- for (const [sessionId, monthMap] of monthlyStats.entries()) {
- const count = monthMap.get(month) || 0
- if (count > maxCount) {
- maxCount = count
- topSessionId = sessionId
- }
- }
- const info = contactInfoMap.get(topSessionId)
- monthlyTopFriends.push({
- month,
- displayName: info?.displayName || (topSessionId ? topSessionId : '暂无'),
- avatarUrl: info?.avatarUrl,
- messageCount: maxCount
- })
- }
-
- let peakDay: ChatPeakDay | null = null
- let maxDayCount = 0
- for (const [day, count] of dailyStats.entries()) {
- if (count > maxDayCount) {
- maxDayCount = count
- const dayContactMap = dailyContactStats.get(day)
- let topFriend = ''
- let topFriendCount = 0
- if (dayContactMap) {
- for (const [sessionId, c] of dayContactMap.entries()) {
- if (c > topFriendCount) {
- topFriendCount = c
- topFriend = contactInfoMap.get(sessionId)?.displayName || sessionId
- }
- }
- }
- peakDay = { date: day, messageCount: count, topFriend, topFriendCount }
- }
- }
-
- let midnightKing: AnnualReportData['midnightKing'] = null
- const totalMidnight = Array.from(midnightStats.values()).reduce((a, b) => a + b, 0)
- if (totalMidnight > 0) {
- let maxMidnight = 0
- let midnightSessionId = ''
- for (const [sessionId, count] of midnightStats.entries()) {
- if (count > maxMidnight) {
- maxMidnight = count
- midnightSessionId = sessionId
- }
- }
- const info = contactInfoMap.get(midnightSessionId)
- midnightKing = {
- displayName: info?.displayName || midnightSessionId,
- count: maxMidnight,
- percentage: Math.round((maxMidnight / totalMidnight) * 1000) / 10
- }
- }
-
- let longestStreak: AnnualReportData['longestStreak'] = null
- if (longestStreakSessionId && longestStreakDays > 0 && longestStreakStart && longestStreakEnd) {
- const info = contactInfoMap.get(longestStreakSessionId)
- longestStreak = {
- friendName: info?.displayName || longestStreakSessionId,
- days: longestStreakDays,
- startDate: this.formatDateYmd(longestStreakStart),
- endDate: this.formatDateYmd(longestStreakEnd)
- }
- }
-
- let mutualFriend: AnnualReportData['mutualFriend'] = null
- let bestRatioDiff = Infinity
- for (const [sessionId, stats] of contactStats.entries()) {
- if (stats.sent >= 50 && stats.received >= 50) {
- const ratio = stats.sent / stats.received
- const ratioDiff = Math.abs(ratio - 1)
- if (ratioDiff < bestRatioDiff) {
- bestRatioDiff = ratioDiff
- const info = contactInfoMap.get(sessionId)
- mutualFriend = {
- displayName: info?.displayName || sessionId,
- avatarUrl: info?.avatarUrl,
- sentCount: stats.sent,
- receivedCount: stats.received,
- ratio: Math.round(ratio * 100) / 100
- }
- }
- }
- }
-
- let socialInitiative: AnnualReportData['socialInitiative'] = null
- let totalInitiated = 0
- let totalReceived = 0
- let topInitiatedSessionId = ''
- let topInitiatedCount = 0
- for (const [sessionId, stats] of conversationStarts.entries()) {
- totalInitiated += stats.initiated
- totalReceived += stats.received
- if (stats.initiated > topInitiatedCount) {
- topInitiatedCount = stats.initiated
- topInitiatedSessionId = sessionId
- }
- }
- const totalConversations = totalInitiated + totalReceived
- if (totalConversations > 0) {
- const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null
- socialInitiative = {
- initiatedChats: totalInitiated,
- receivedChats: totalReceived,
- initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10,
- topInitiatedFriend: topInitiatedCount > 0
- ? (topInitiatedInfo?.displayName || topInitiatedSessionId)
- : undefined,
- topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined
- }
- }
-
- this.reportProgress('生成报告...', 95, onProgress)
-
- let responseSpeed: AnnualReportData['responseSpeed'] = null
- if (responseStatsFromSql && Object.keys(responseStatsFromSql).length > 0) {
- let totalSum = 0
- let totalCount = 0
- let fastestFriendId = ''
- let fastestAvgTime = Infinity
- for (const [sessionId, stats] of Object.entries(responseStatsFromSql)) {
- const count = stats.count || 0
- const avg = stats.avg || 0
- if (count <= 0 || avg <= 0) continue
- totalSum += avg * count
- totalCount += count
- if (avg < fastestAvgTime) {
- fastestAvgTime = avg
- fastestFriendId = sessionId
- }
- }
- if (totalCount > 0) {
- const avgResponseTime = totalSum / totalCount
- const fastestInfo = contactInfoMap.get(fastestFriendId)
- responseSpeed = {
- avgResponseTime: Math.round(avgResponseTime),
- fastestFriend: fastestInfo?.displayName || fastestFriendId,
- fastestTime: Math.round(fastestAvgTime)
- }
- }
- } else {
- const allResponseTimes: number[] = []
- let fastestFriendId = ''
- let fastestAvgTime = Infinity
- for (const [sessionId, times] of responseTimeStats.entries()) {
- if (times.length >= 10) {
- allResponseTimes.push(...times)
- const avgTime = times.reduce((a, b) => a + b, 0) / times.length
- if (avgTime < fastestAvgTime) {
- fastestAvgTime = avgTime
- fastestFriendId = sessionId
- }
- }
- }
- if (allResponseTimes.length > 0) {
- const avgResponseTime = allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length
- const fastestInfo = contactInfoMap.get(fastestFriendId)
- responseSpeed = {
- avgResponseTime: Math.round(avgResponseTime),
- fastestFriend: fastestInfo?.displayName || fastestFriendId,
- fastestTime: Math.round(fastestAvgTime)
- }
- }
- }
-
- const topPhrases = topPhrasesFromSql && topPhrasesFromSql.length > 0
- ? topPhrasesFromSql
- : Array.from(phraseCount.entries())
- .filter(([_, count]) => count >= 2)
- .sort((a, b) => b[1] - a[1])
- .slice(0, 32)
- .map(([phrase, count]) => ({ phrase, count }))
-
- // 曾经的好朋友 (Once Best Friend / Lost Friend)
- let lostFriend: AnnualReportData['lostFriend'] = null
- let maxEarlyCount = 80 // 最低门槛
- let bestEarlyCount = 0
- let bestLateCount = 0
- let bestSid = ''
- let bestPeriodDesc = ''
-
- const currentMonthIndex = new Date().getMonth() + 1 // 1-12
-
- const currentYearNum = now.getFullYear()
-
- if (isAllTime) {
- const days = Object.keys(d.daily).sort()
- if (days.length >= 2) {
- const firstDay = Math.floor(new Date(days[0]).getTime() / 1000)
- const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000)
- const midPoint = Math.floor((firstDay + lastDay) / 2)
-
- this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress)
- const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint)
- this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress)
- const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0)
-
- if (earlyRes.success && lateRes.success && earlyRes.data) {
- const earlyData = earlyRes.data.sessions || {}
- const lateData = (lateRes.data?.sessions) || {}
- for (const sid of sessionIds) {
- const e = earlyData[sid] || { sent: 0, received: 0 }
- const l = lateData[sid] || { sent: 0, received: 0 }
- const early = (e.sent || 0) + (e.received || 0)
- const late = (l.sent || 0) + (l.received || 0)
- if (early > 100 && early > late * 5) {
- // 选择前期消息量最多的
- if (early > maxEarlyCount) {
- maxEarlyCount = early
- bestEarlyCount = early
- bestLateCount = late
- bestSid = sid
- bestPeriodDesc = '这段时间以来'
- }
- }
- }
- }
- }
- } else if (year === currentYearNum) {
- // 当前年份:独立获取过去12个月的滚动数据
- this.reportProgress('分析近期好友趋势...', 86, onProgress)
- // 往前数12个月的起点、中点、终点
- const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000)
- const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000)
- const rollingEnd = Math.floor(now.getTime() / 1000)
-
- const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1)
- const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd)
-
- if (earlyRes.success && lateRes.success && earlyRes.data) {
- const earlyData = earlyRes.data.sessions || {}
- const lateData = lateRes.data?.sessions || {}
- for (const sid of sessionIds) {
- const e = earlyData[sid] || { sent: 0, received: 0 }
- const l = lateData[sid] || { sent: 0, received: 0 }
- const early = (e.sent || 0) + (e.received || 0)
- const late = (l.sent || 0) + (l.received || 0)
- if (early > 80 && early > late * 5) {
- // 选择前期消息量最多的
- if (early > maxEarlyCount) {
- maxEarlyCount = early
- bestEarlyCount = early
- bestLateCount = late
- bestSid = sid
- bestPeriodDesc = '去年的这个时候'
- }
- }
- }
- }
- } else {
- // 指定完整年份 (1-6 vs 7-12)
- for (const [sid, stat] of Object.entries(d.sessions)) {
- const s = stat as any
- const mWeights = s.monthly || {}
- let early = 0
- let late = 0
- for (let m = 1; m <= 6; m++) early += mWeights[m] || 0
- for (let m = 7; m <= 12; m++) late += mWeights[m] || 0
-
- if (early > 80 && early > late * 5) {
- // 选择前期消息量最多的
- if (early > maxEarlyCount) {
- maxEarlyCount = early
- bestEarlyCount = early
- bestLateCount = late
- bestSid = sid
- bestPeriodDesc = `${year}年上半年`
- }
- }
- }
- }
-
- if (bestSid) {
- let info = contactInfoMap.get(bestSid)
- // 如果 contactInfoMap 中没有该联系人,则单独获取
- if (!info) {
- const [displayNameRes, avatarUrlRes] = await Promise.all([
- wcdbService.getDisplayNames([bestSid]),
- wcdbService.getAvatarUrls([bestSid])
- ])
- info = {
- displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid,
- avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined
- }
- }
- lostFriend = {
- username: bestSid,
- displayName: info?.displayName || bestSid,
- avatarUrl: info?.avatarUrl,
- earlyCount: bestEarlyCount,
- lateCount: bestLateCount,
- periodDesc: bestPeriodDesc
- }
- }
-
- const reportData: AnnualReportData = {
- year: reportYear,
- totalMessages,
- totalFriends: contactStats.size,
- coreFriends,
- monthlyTopFriends,
- peakDay,
- longestStreak,
- activityHeatmap: { data: heatmapData },
- midnightKing,
- selfAvatarUrl,
- mutualFriend,
- socialInitiative,
- responseSpeed,
- topPhrases,
- snsStats: snsStatsResult,
- lostFriend
- }
-
- return { success: true, data: reportData }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-}
-
-export const annualReportService = new AnnualReportService()
diff --git a/electron/services/avatarFileCacheService.ts b/electron/services/avatarFileCacheService.ts
deleted file mode 100644
index 7216154..0000000
--- a/electron/services/avatarFileCacheService.ts
+++ /dev/null
@@ -1,219 +0,0 @@
-import https from "https";
-import http, { IncomingMessage } from "http";
-import { promises as fs } from "fs";
-import { join } from "path";
-import { ConfigService } from "./config";
-
-// 头像文件缓存服务 - 复用项目已有的缓存目录结构
-export class AvatarFileCacheService {
- private static instance: AvatarFileCacheService | null = null;
-
- // 头像文件缓存目录
- private readonly cacheDir: string;
- // 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
- private readonly pendingDownloads: Map> =
- new Map();
- // LRU 追踪:文件路径->最后访问时间
- private readonly lruOrder: string[] = [];
- private readonly maxCacheFiles = 100;
-
- private constructor() {
- const basePath = ConfigService.getInstance().getCacheBasePath();
- this.cacheDir = join(basePath, "avatar-files");
- this.ensureCacheDir();
- this.loadLruOrder();
- }
-
- public static getInstance(): AvatarFileCacheService {
- if (!AvatarFileCacheService.instance) {
- AvatarFileCacheService.instance = new AvatarFileCacheService();
- }
- return AvatarFileCacheService.instance;
- }
-
- private ensureCacheDir(): void {
- // 同步确保目录存在(构造函数调用)
- try {
- fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
- } catch {}
- }
-
- private async ensureCacheDirAsync(): Promise {
- try {
- await fs.mkdir(this.cacheDir, { recursive: true });
- } catch {}
- }
-
- private getFilePath(url: string): string {
- // 使用URL的hash作为文件名,避免特殊字符问题
- const hash = this.hashString(url);
- return join(this.cacheDir, `avatar_${hash}.png`);
- }
-
- private hashString(str: string): string {
- let hash = 0;
- for (let i = 0; i < str.length; i++) {
- const char = str.charCodeAt(i);
- hash = (hash << 5) - hash + char;
- hash = hash & hash; // 转换为32位整数
- }
- return Math.abs(hash).toString(16);
- }
-
- private async loadLruOrder(): Promise {
- try {
- const entries = await fs.readdir(this.cacheDir);
- // 按修改时间排序(旧的在前)
- const filesWithTime: { file: string; mtime: number }[] = [];
- for (const entry of entries) {
- if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
- try {
- const stat = await fs.stat(join(this.cacheDir, entry));
- filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
- } catch {}
- }
- filesWithTime.sort((a, b) => a.mtime - b.mtime);
- this.lruOrder.length = 0;
- this.lruOrder.push(...filesWithTime.map((f) => f.file));
- } catch {}
- }
-
- private updateLru(fileName: string): void {
- const index = this.lruOrder.indexOf(fileName);
- if (index > -1) {
- this.lruOrder.splice(index, 1);
- }
- this.lruOrder.push(fileName);
- }
-
- private async evictIfNeeded(): Promise {
- while (this.lruOrder.length >= this.maxCacheFiles) {
- const oldest = this.lruOrder.shift();
- if (oldest) {
- try {
- await fs.rm(join(this.cacheDir, oldest));
- console.log(`[AvatarFileCache] Evicted: ${oldest}`);
- } catch {}
- }
- }
- }
-
- private async downloadAvatar(url: string): Promise {
- const localPath = this.getFilePath(url);
-
- // 检查文件是否已存在
- try {
- await fs.access(localPath);
- const fileName = localPath.split("/").pop()!;
- this.updateLru(fileName);
- return localPath;
- } catch {}
-
- await this.ensureCacheDirAsync();
- await this.evictIfNeeded();
-
- return new Promise((resolve) => {
- const options = {
- headers: {
- "User-Agent":
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
- Referer: "https://servicewechat.com/",
- Accept:
- "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
- "Accept-Encoding": "gzip, deflate, br",
- "Accept-Language": "zh-CN,zh;q=0.9",
- Connection: "keep-alive",
- },
- };
-
- const callback = (res: IncomingMessage) => {
- if (res.statusCode !== 200) {
- resolve(null);
- return;
- }
- const chunks: Buffer[] = [];
- res.on("data", (chunk: Buffer) => chunks.push(chunk));
- res.on("end", async () => {
- try {
- const buffer = Buffer.concat(chunks);
- await fs.writeFile(localPath, buffer);
- const fileName = localPath.split("/").pop()!;
- this.updateLru(fileName);
- console.log(
- `[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
- );
- resolve(localPath);
- } catch {
- resolve(null);
- }
- });
- res.on("error", () => resolve(null));
- };
-
- const req = url.startsWith("https")
- ? https.get(url, options, callback)
- : http.get(url, options, callback);
-
- req.on("error", () => resolve(null));
- req.setTimeout(10000, () => {
- req.destroy();
- resolve(null);
- });
- });
- }
-
- /**
- * 获取头像本地文件路径,如果需要会下载
- * 同一URL并发调用会复用同一个下载任务
- */
- async getAvatarPath(url: string): Promise {
- if (!url) return null;
-
- // 检查是否有正在进行的下载
- const pending = this.pendingDownloads.get(url);
- if (pending) {
- return pending;
- }
-
- // 发起新下载
- const downloadPromise = this.downloadAvatar(url);
- this.pendingDownloads.set(url, downloadPromise);
-
- try {
- const result = await downloadPromise;
- return result;
- } finally {
- this.pendingDownloads.delete(url);
- }
- }
-
- // 清理所有缓存文件(App退出时调用)
- async clearCache(): Promise {
- try {
- const entries = await fs.readdir(this.cacheDir);
- for (const entry of entries) {
- if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
- try {
- await fs.rm(join(this.cacheDir, entry));
- } catch {}
- }
- }
- this.lruOrder.length = 0;
- console.log("[AvatarFileCache] Cache cleared");
- } catch {}
- }
-
- // 获取当前缓存的文件数量
- async getCacheCount(): Promise {
- try {
- const entries = await fs.readdir(this.cacheDir);
- return entries.filter(
- (e) => e.startsWith("avatar_") && e.endsWith(".png"),
- ).length;
- } catch {
- return 0;
- }
- }
-}
-
-export const avatarFileCache = AvatarFileCacheService.getInstance();
diff --git a/electron/services/backupService.ts b/electron/services/backupService.ts
deleted file mode 100644
index feac28e..0000000
--- a/electron/services/backupService.ts
+++ /dev/null
@@ -1,1088 +0,0 @@
-import { BrowserWindow, app } from 'electron'
-import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'fs'
-import { copyFile, link, readFile as readFileAsync, mkdtemp, writeFile } from 'fs/promises'
-import { basename, dirname, join, relative, resolve, sep } from 'path'
-import { tmpdir } from 'os'
-import * as tar from 'tar'
-import { ConfigService } from './config'
-import { wcdbService } from './wcdbService'
-import { expandHomePath } from '../utils/pathUtils'
-
-type BackupDbKind = 'session' | 'contact' | 'emoticon' | 'message' | 'media' | 'sns' | 'hardlink'
-type BackupPhase = 'preparing' | 'scanning' | 'exporting' | 'packing' | 'inspecting' | 'restoring' | 'done' | 'failed'
-type BackupResourceKind = 'image' | 'video' | 'file'
-const TEMP_MARKER = '.weflow-backup-temp'
-const TEMP_TTL_MS = 24 * 60 * 60 * 1000
-
-export interface BackupOptions {
- includeImages?: boolean
- includeVideos?: boolean
- includeFiles?: boolean
-}
-
-interface BackupDbEntry {
- id: string
- kind: BackupDbKind
- dbPath: string
- relativePath: string
- tables: BackupTableEntry[]
-}
-
-interface BackupTableEntry {
- name: string
- snapshotPath: string
- rows: number
- columns: number
- schemaSql?: string
-}
-
-interface BackupResourceEntry {
- kind: BackupResourceKind
- id: string
- md5?: string
- sessionId?: string
- createTime?: number
- sourceFileName?: string
- archivePath: string
- targetRelativePath: string
- ext?: string
- size?: number
-}
-
-interface BackupManifest {
- version: 1
- type: 'weflow-db-snapshots'
- createdAt: string
- appVersion: string
- source: {
- wxid: string
- dbRoot: string
- }
- databases: BackupDbEntry[]
- options?: BackupOptions
- resources?: {
- images?: BackupResourceEntry[]
- videos?: BackupResourceEntry[]
- files?: BackupResourceEntry[]
- }
-}
-
-interface BackupProgress {
- phase: BackupPhase
- message: string
- current?: number
- total?: number
- detail?: string
-}
-
-function emitBackupProgress(progress: BackupProgress): void {
- for (const win of BrowserWindow.getAllWindows()) {
- if (!win.isDestroyed()) {
- win.webContents.send('backup:progress', progress)
- }
- }
-}
-
-function safeName(value: string): string {
- return encodeURIComponent(value || 'unnamed').replace(/%/g, '_')
-}
-
-function toArchivePath(path: string): string {
- return path.split(sep).join('/')
-}
-
-async function withTimeout(task: Promise, timeoutMs: number, message: string): Promise {
- let timer: NodeJS.Timeout | null = null
- try {
- return await Promise.race([
- task,
- new Promise((_, reject) => {
- timer = setTimeout(() => reject(new Error(message)), timeoutMs)
- })
- ])
- } finally {
- if (timer) clearTimeout(timer)
- }
-}
-
-function delay(ms = 0): Promise {
- return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
-}
-
-function createThrottledProgressEmitter(minIntervalMs = 120): (progress: BackupProgress, force?: boolean) => void {
- let lastEmitAt = 0
- return (progress: BackupProgress, force = false) => {
- const now = Date.now()
- if (!force && now - lastEmitAt < minIntervalMs) return
- lastEmitAt = now
- emitBackupProgress(progress)
- }
-}
-
-async function runWithConcurrency(
- items: T[],
- concurrency: number,
- worker: (item: T, index: number) => Promise
-): Promise {
- let nextIndex = 0
- const workerCount = Math.max(1, Math.min(concurrency, items.length))
- await Promise.all(Array.from({ length: workerCount }, async () => {
- while (true) {
- const index = nextIndex
- nextIndex += 1
- if (index >= items.length) return
- await worker(items[index], index)
- if (index % 50 === 0) await delay()
- }
- }))
-}
-
-function hasResourceOptions(options: BackupOptions): boolean {
- return options.includeImages === true || options.includeVideos === true || options.includeFiles === true
-}
-
-function normalizeArchivePath(value: string): string {
- return String(value || '').replace(/\\/g, '/')
-}
-
-export class BackupService {
- private configService = new ConfigService()
- private cleanedTempDirs = false
-
- private cleanupStaleTempDirs(): void {
- if (this.cleanedTempDirs) return
- this.cleanedTempDirs = true
- const root = tmpdir()
- const now = Date.now()
- try {
- for (const entry of readdirSync(root)) {
- if (!entry.startsWith('weflow-backup-')) continue
- const dir = join(root, entry)
- const marker = join(dir, TEMP_MARKER)
- try {
- const stat = statSync(dir)
- if (!stat.isDirectory()) continue
- if (!existsSync(marker)) continue
- const age = now - stat.mtimeMs
- if (age < TEMP_TTL_MS) continue
- rmSync(dir, { recursive: true, force: true })
- } catch {}
- }
- } catch {}
- }
-
- private async createTempDir(prefix: string): Promise {
- this.cleanupStaleTempDirs()
- const dir = await mkdtemp(join(tmpdir(), prefix))
- await writeFile(join(dir, TEMP_MARKER), String(Date.now()), 'utf8')
- return dir
- }
-
- private buildWxidCandidates(wxid: string): string[] {
- const wxidCandidates = Array.from(new Set([
- String(wxid || '').trim(),
- this.cleanAccountDirName(wxid)
- ].filter(Boolean)))
- return wxidCandidates
- }
-
- private isCurrentAccountDir(accountDir: string, wxidCandidates: string[]): boolean {
- const accountName = basename(accountDir).toLowerCase()
- return wxidCandidates
- .map(item => item.toLowerCase())
- .some(wxid => accountName === wxid || accountName.startsWith(`${wxid}_`))
- }
-
- private normalizeExistingPath(inputPath: string): string {
- const expanded = expandHomePath(String(inputPath || '').trim()).replace(/[\\/]+$/, '')
- if (!expanded) return expanded
- try {
- if (existsSync(expanded) && statSync(expanded).isFile()) {
- return dirname(expanded)
- }
- } catch {}
- return expanded
- }
-
- private resolveAncestorDbStorage(normalized: string, wxidCandidates: string[]): string | null {
- let current = normalized
- for (let i = 0; i < 8; i += 1) {
- if (!current) break
- if (basename(current).toLowerCase() === 'db_storage') {
- const accountDir = dirname(current)
- if (this.isCurrentAccountDir(accountDir, wxidCandidates) && existsSync(current)) {
- return current
- }
- }
- const parent = dirname(current)
- if (!parent || parent === current) break
- current = parent
- }
- return null
- }
-
- private resolveCurrentAccountDbStorageFromRoot(rootPath: string, wxidCandidates: string[]): string | null {
- if (!rootPath || !existsSync(rootPath)) return null
-
- for (const candidateWxid of wxidCandidates) {
- const viaWxid = join(rootPath, candidateWxid, 'db_storage')
- if (existsSync(viaWxid)) return viaWxid
- }
-
- try {
- const entries = readdirSync(rootPath)
- const loweredWxids = wxidCandidates.map(item => item.toLowerCase())
- for (const entry of entries) {
- const entryPath = join(rootPath, entry)
- try {
- if (!statSync(entryPath).isDirectory()) continue
- } catch {
- continue
- }
- const lowerEntry = entry.toLowerCase()
- if (!loweredWxids.some(id => lowerEntry === id || lowerEntry.startsWith(`${id}_`))) continue
- const candidate = join(entryPath, 'db_storage')
- if (existsSync(candidate)) return candidate
- }
- } catch {}
-
- return null
- }
-
- private resolveDbStoragePath(dbPath: string, wxid: string): string | null {
- const normalized = this.normalizeExistingPath(dbPath)
- if (!normalized) return null
-
- const wxidCandidates = this.buildWxidCandidates(wxid)
- const ancestor = this.resolveAncestorDbStorage(normalized, wxidCandidates)
- if (ancestor) return ancestor
-
- const direct = join(normalized, 'db_storage')
- if (existsSync(direct) && this.isCurrentAccountDir(normalized, wxidCandidates)) return direct
-
- const roots = Array.from(new Set([
- normalized,
- join(normalized, 'WeChat Files'),
- join(normalized, 'xwechat_files')
- ]))
- for (const root of roots) {
- const dbStorage = this.resolveCurrentAccountDbStorageFromRoot(root, wxidCandidates)
- if (dbStorage) return dbStorage
- }
-
- return null
- }
-
- private resolveAccountDir(dbPath: string, wxid: string): string | null {
- const dbStorage = this.resolveDbStoragePath(dbPath, wxid)
- return dbStorage ? dirname(dbStorage) : null
- }
-
- private cleanAccountDirName(wxid: string): string {
- const trimmed = String(wxid || '').trim()
- if (trimmed.toLowerCase().startsWith('wxid_')) {
- const match = trimmed.match(/^(wxid_[^_]+)/i)
- return match?.[1] || trimmed
- }
- const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
- return suffixMatch ? suffixMatch[1] : trimmed
- }
-
- private async listFilesForArchive(root: string, rel = '', state = { visited: 0 }): Promise {
- const dir = join(root, rel)
- const files: string[] = []
- for (const entry of readdirSync(dir)) {
- const entryRel = rel ? join(rel, entry) : entry
- const entryPath = join(root, entryRel)
- try {
- const stat = statSync(entryPath)
- if (stat.isDirectory()) {
- files.push(...await this.listFilesForArchive(root, entryRel, state))
- } else if (stat.isFile()) {
- files.push(toArchivePath(entryRel))
- }
- state.visited += 1
- if (state.visited % 200 === 0) await delay()
- } catch {}
- }
- return files
- }
-
- private resolveExtractedPath(extractDir: string, archivePath: string): string | null {
- const normalized = normalizeArchivePath(archivePath)
- if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
- const root = resolve(extractDir)
- const target = resolve(join(extractDir, normalized))
- if (target !== root && !target.startsWith(`${root}${sep}`)) return null
- return target
- }
-
- private resolveStagingPath(stagingDir: string, archivePath: string): string | null {
- return this.resolveExtractedPath(stagingDir, archivePath)
- }
-
- private resolveTargetResourcePath(accountDir: string, relativePath: string): string | null {
- const normalized = normalizeArchivePath(relativePath)
- if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
- const root = resolve(accountDir)
- const target = resolve(join(accountDir, normalized))
- if (target !== root && !target.startsWith(`${root}${sep}`)) return null
- return target
- }
-
- private isSafeAccountRelativePath(accountDir: string, filePath: string): string | null {
- const rel = toArchivePath(relative(accountDir, filePath))
- if (!rel || rel.startsWith('..') || rel.startsWith('/')) return null
- return rel
- }
-
- private async listFilesUnderDir(root: string, state = { visited: 0 }): Promise {
- const files: string[] = []
- if (!existsSync(root)) return files
- try {
- for (const entry of readdirSync(root)) {
- const fullPath = join(root, entry)
- let stat
- try {
- stat = statSync(fullPath)
- } catch {
- continue
- }
- if (stat.isDirectory()) {
- files.push(...await this.listFilesUnderDir(fullPath, state))
- } else if (stat.isFile()) {
- files.push(fullPath)
- }
- state.visited += 1
- if (state.visited % 300 === 0) await delay()
- }
- } catch {}
- return files
- }
-
- private async stagePlainResource(sourcePath: string, outputPath: string): Promise {
- mkdirSync(dirname(outputPath), { recursive: true })
- try {
- await link(sourcePath, outputPath)
- } catch {
- await copyFile(sourcePath, outputPath)
- }
- }
-
- private async writeTarEntryToFile(entry: any, outputPath: string): Promise {
- mkdirSync(dirname(outputPath), { recursive: true })
- await new Promise((resolvePromise, rejectPromise) => {
- const out = createWriteStream(outputPath)
- const fail = (error: unknown) => rejectPromise(error instanceof Error ? error : new Error(String(error)))
- out.on('finish', resolvePromise)
- out.on('error', fail)
- entry.on('error', fail)
- entry.pipe(out)
- })
- }
-
- private async listChatImageDatFiles(accountDir: string): Promise {
- const attachRoot = join(accountDir, 'msg', 'attach')
- const result: string[] = []
- if (!existsSync(attachRoot)) return result
-
- const scanImgDir = async (imgDir: string): Promise => {
- let entries: string[] = []
- try {
- entries = readdirSync(imgDir)
- } catch {
- return
- }
- for (const entry of entries) {
- const fullPath = join(imgDir, entry)
- let stat
- try {
- stat = statSync(fullPath)
- } catch {
- continue
- }
- if (stat.isFile() && entry.toLowerCase().endsWith('.dat')) {
- result.push(fullPath)
- } else if (stat.isDirectory()) {
- let nestedEntries: string[] = []
- try {
- nestedEntries = readdirSync(fullPath)
- } catch {
- continue
- }
- for (const nestedEntry of nestedEntries) {
- const nestedPath = join(fullPath, nestedEntry)
- try {
- if (statSync(nestedPath).isFile() && nestedEntry.toLowerCase().endsWith('.dat')) {
- result.push(nestedPath)
- }
- } catch {}
- }
- }
- if (result.length > 0 && result.length % 500 === 0) await delay()
- }
- }
-
- const walk = async (dir: string): Promise => {
- let entries: Array<{ name: string; isDirectory: () => boolean }> = []
- try {
- entries = readdirSync(dir, { withFileTypes: true })
- } catch {
- return
- }
- for (const entry of entries) {
- if (!entry.isDirectory()) continue
- const child = join(dir, entry.name)
- if (entry.name.toLowerCase() === 'img') {
- await scanImgDir(child)
- } else {
- await walk(child)
- }
- if (result.length > 0 && result.length % 500 === 0) await delay()
- }
- }
-
- await walk(attachRoot)
- return Array.from(new Set(result))
- }
-
- private async ensureConnected(wxidOverride?: string): Promise<{ success: boolean; wxid?: string; dbPath?: string; dbStorage?: string; error?: string }> {
- const configuredWxid = String(this.configService.getMyWxidCleaned() || '').trim()
- const wxid = String(wxidOverride || configuredWxid || '').trim()
- const dbPath = String(this.configService.get('dbPath') || '').trim()
- const decryptKey = String(this.configService.get('decryptKey') || '').trim()
- if (!wxid || !dbPath) return { success: false, error: '请先配置数据库路径和微信账号' }
- if (!decryptKey) return { success: false, error: '请先配置数据库解密密钥' }
-
- // 使用 ConfigService 统一解析账号目录
- const accountDir = this.configService.getAccountDir(dbPath, wxid)
- if (!accountDir) return { success: false, error: `未在配置的 dbPath 下找到账号目录:${wxid}` }
- const dbStorage = join(accountDir, 'db_storage')
- if (!existsSync(dbStorage)) return { success: false, error: '未找到 db_storage 目录' }
-
- const accountDirName = basename(accountDir)
- const opened = await withTimeout(
- wcdbService.open(accountDir, decryptKey),
- 15000,
- '连接目标账号数据库超时,请检查数据库路径、密钥是否正确'
- )
- if (!opened) {
- const detail = await wcdbService.getLastInitError().catch(() => null)
- return { success: false, error: detail || `目标账号 ${accountDir} 数据库连接失败` }
- }
-
- return { success: true, wxid: accountDir, dbPath, dbStorage }
- }
-
- private buildDbId(kind: BackupDbKind, index: number, dbPath: string): string {
- if (kind === 'session' || kind === 'contact' || kind === 'emoticon' || kind === 'sns' || kind === 'hardlink') return kind
- return `${kind}-${index}-${safeName(basename(dbPath)).slice(0, 80)}`
- }
-
- private toDbRelativePath(dbStorage: string, dbPath: string): string {
- const rel = toArchivePath(relative(dbStorage, dbPath))
- if (!rel || rel.startsWith('..') || rel.startsWith('/')) return basename(dbPath)
- return rel
- }
-
- private resolveTargetDbPath(dbStorage: string, relativePath: string): string | null {
- const normalized = String(relativePath || '').replace(/\\/g, '/')
- if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) return null
- const root = resolve(dbStorage)
- const target = resolve(join(dbStorage, normalized))
- if (target !== root && !target.startsWith(`${root}${sep}`)) return null
- return target
- }
-
- private defaultRelativeDbPath(kind: BackupDbKind): string | null {
- if (kind === 'session') return 'session/session.db'
- if (kind === 'contact') return 'contact/contact.db'
- if (kind === 'emoticon') return 'emoticon/emoticon.db'
- if (kind === 'sns') return 'sns/sns.db'
- if (kind === 'hardlink') return 'hardlink/hardlink.db'
- return null
- }
-
- private resolveRestoreTargetDbPath(dbStorage: string, db: BackupDbEntry): string | null {
- const normalized = String(db.relativePath || '').replace(/\\/g, '/')
- const legacyFixedPath = this.defaultRelativeDbPath(db.kind)
- if (legacyFixedPath && (!normalized.includes('/') || !normalized.toLowerCase().endsWith('.db'))) {
- return this.resolveTargetDbPath(dbStorage, legacyFixedPath)
- }
- return this.resolveTargetDbPath(dbStorage, db.relativePath)
- }
-
- private findFirstExisting(paths: string[]): string {
- for (const path of paths) {
- try {
- if (existsSync(path) && statSync(path).isFile()) return path
- } catch {}
- }
- return ''
- }
-
- private resolveKnownDbPath(kind: BackupDbKind, dbStorage: string): string {
- if (kind === 'session') {
- return this.findFirstExisting([
- join(dbStorage, 'session', 'session.db'),
- join(dbStorage, 'Session', 'session.db'),
- join(dbStorage, 'session.db')
- ])
- }
- if (kind === 'contact') {
- return this.findFirstExisting([
- join(dbStorage, 'Contact', 'contact.db'),
- join(dbStorage, 'Contact', 'Contact.db'),
- join(dbStorage, 'contact', 'contact.db'),
- join(dbStorage, 'session', 'contact.db')
- ])
- }
- if (kind === 'emoticon') {
- return this.findFirstExisting([
- join(dbStorage, 'emoticon', 'emoticon.db'),
- join(dbStorage, 'emotion', 'emoticon.db')
- ])
- }
- if (kind === 'sns') {
- return this.findFirstExisting([
- join(dbStorage, 'sns', 'sns.db'),
- join(dirname(dbStorage), 'sns', 'sns.db')
- ])
- }
- if (kind === 'hardlink') {
- return this.findFirstExisting([
- join(dbStorage, 'hardlink', 'hardlink.db'),
- join(dbStorage, 'hardlink.db'),
- join(dirname(dbStorage), 'hardlink.db')
- ])
- }
- return ''
- }
-
- private async collectDatabases(dbStorage: string): Promise>> {
- const result: Array> = []
- for (const kind of ['session', 'contact', 'emoticon', 'sns', 'hardlink'] as const) {
- const dbPath = this.resolveKnownDbPath(kind, dbStorage)
- result.push({
- id: kind,
- kind,
- dbPath,
- relativePath: dbPath ? this.toDbRelativePath(dbStorage, dbPath) : kind
- })
- }
-
- const messageDbs = await wcdbService.listMessageDbs()
- if (messageDbs.success && Array.isArray(messageDbs.data)) {
- messageDbs.data.forEach((dbPath, index) => {
- result.push({
- id: this.buildDbId('message', index, dbPath),
- kind: 'message',
- dbPath,
- relativePath: this.toDbRelativePath(dbStorage, dbPath)
- })
- })
- }
-
- const mediaDbs = await wcdbService.listMediaDbs()
- if (mediaDbs.success && Array.isArray(mediaDbs.data)) {
- mediaDbs.data.forEach((dbPath, index) => {
- result.push({
- id: this.buildDbId('media', index, dbPath),
- kind: 'media',
- dbPath,
- relativePath: this.toDbRelativePath(dbStorage, dbPath)
- })
- })
- }
-
- return result
- }
-
- private async collectImageResources(
- connected: { wxid: string; dbStorage: string },
- stagingDir: string,
- manifest: BackupManifest
- ): Promise {
- const accountDir = dirname(connected.dbStorage)
- const imagesDir = join(stagingDir, 'resources', 'images')
- const imagePaths = await this.listChatImageDatFiles(accountDir)
- if (imagePaths.length === 0) return
-
- mkdirSync(imagesDir, { recursive: true })
- const resources: BackupResourceEntry[] = []
- const emitImageProgress = createThrottledProgressEmitter(160)
- for (let index = 0; index < imagePaths.length; index += 1) {
- const sourcePath = imagePaths[index]
- const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
- if (!relativeTarget) continue
- emitImageProgress({
- phase: 'exporting',
- message: '正在打包图片资源',
- current: index + 1,
- total: imagePaths.length,
- detail: relativeTarget
- })
- const archivePath = toArchivePath(join('resources', 'images', relativeTarget))
- const outputPath = join(stagingDir, archivePath)
- await this.stagePlainResource(sourcePath, outputPath)
- const stem = basename(sourcePath).replace(/\.dat$/i, '').toLowerCase()
- const stat = statSync(sourcePath)
- resources.push({
- kind: 'image',
- id: relativeTarget,
- md5: /^[a-f0-9]{32}$/i.test(stem) ? stem : undefined,
- sourceFileName: basename(sourcePath),
- archivePath,
- targetRelativePath: relativeTarget,
- size: stat.size
- })
- if (index % 20 === 0) await delay()
- }
-
- if (resources.length > 0) {
- manifest.resources = { ...(manifest.resources || {}), images: resources }
- }
- }
-
- private async collectPlainResources(
- connected: { dbStorage: string },
- stagingDir: string,
- manifest: BackupManifest,
- kind: 'video' | 'file'
- ): Promise {
- const accountDir = dirname(connected.dbStorage)
- const roots = kind === 'video'
- ? [
- join(accountDir, 'msg', 'video'),
- join(accountDir, 'FileStorage', 'Video')
- ]
- : [
- join(accountDir, 'FileStorage', 'File'),
- join(accountDir, 'msg', 'file')
- ]
- const listed = await Promise.all(roots.map(root => this.listFilesUnderDir(root)))
- const uniqueFiles = Array.from(new Set(listed.flat()))
- if (uniqueFiles.length === 0) return
-
- const resources: BackupResourceEntry[] = []
- const bucket = kind === 'video' ? 'videos' : 'files'
- const emitResourceProgress = createThrottledProgressEmitter(180)
- await runWithConcurrency(uniqueFiles, 4, async (sourcePath, index) => {
- emitResourceProgress({
- phase: 'exporting',
- message: kind === 'video' ? '正在归档视频资源' : '正在归档文件资源',
- current: index + 1,
- total: uniqueFiles.length,
- detail: basename(sourcePath)
- })
- const relativeTarget = this.isSafeAccountRelativePath(accountDir, sourcePath)
- if (!relativeTarget) return
- const archivePath = toArchivePath(join('resources', bucket, relativeTarget))
- const outputPath = join(stagingDir, archivePath)
- await this.stagePlainResource(sourcePath, outputPath)
- let size = 0
- try { size = statSync(sourcePath).size } catch {}
- const entry: BackupResourceEntry = {
- kind,
- id: relativeTarget,
- sourceFileName: basename(sourcePath),
- archivePath,
- targetRelativePath: relativeTarget,
- size
- }
- resources.push(entry)
- })
-
- if (resources.length > 0) {
- manifest.resources = {
- ...(manifest.resources || {}),
- [bucket]: resources
- }
- }
- }
-
- async createBackup(outputPath: string, options: BackupOptions = {}): Promise<{ success: boolean; filePath?: string; manifest?: BackupManifest; error?: string }> {
- let stagingDir = ''
- try {
- emitBackupProgress({ phase: 'preparing', message: '正在连接数据库' })
- const connected = await this.ensureConnected()
- if (!connected.success || !connected.wxid || !connected.dbPath || !connected.dbStorage) {
- return { success: false, error: connected.error || '数据库未连接' }
- }
-
- stagingDir = await this.createTempDir('weflow-backup-')
- const snapshotsDir = join(stagingDir, 'snapshots')
- mkdirSync(snapshotsDir, { recursive: true })
-
- const dbs = await this.collectDatabases(connected.dbStorage)
- const manifest: BackupManifest = {
- version: 1,
- type: 'weflow-db-snapshots',
- createdAt: new Date().toISOString(),
- appVersion: app.getVersion(),
- source: {
- wxid: connected.wxid,
- dbRoot: connected.dbPath
- },
- databases: [],
- options: {
- includeImages: options.includeImages === true,
- includeVideos: options.includeVideos === true,
- includeFiles: options.includeFiles === true
- }
- }
-
- const tableJobs: Array<{ db: Omit; table: string; schemaSql: string; snapshotPath: string; outputPath: string }> = []
- for (let index = 0; index < dbs.length; index += 1) {
- const db = dbs[index]
- emitBackupProgress({
- phase: 'scanning',
- message: '正在扫描数据库和表',
- current: index + 1,
- total: dbs.length,
- detail: `${db.kind}:${db.relativePath || db.dbPath || db.id}`
- })
- const tablesResult = await wcdbService.listTables(db.kind, db.dbPath)
- if (!tablesResult.success || !Array.isArray(tablesResult.tables) || tablesResult.tables.length === 0) continue
- const dbDir = join(snapshotsDir, db.id)
- mkdirSync(dbDir, { recursive: true })
- const entry: BackupDbEntry = { ...db, tables: [] }
- manifest.databases.push(entry)
- for (const table of tablesResult.tables) {
- const schemaResult = await wcdbService.getTableSchema(db.kind, db.dbPath, table)
- if (!schemaResult.success || !schemaResult.schema) continue
- const snapshotPath = toArchivePath(join('snapshots', db.id, `${safeName(table)}.wfsnap`))
- tableJobs.push({
- db,
- table,
- schemaSql: schemaResult.schema,
- snapshotPath,
- outputPath: join(stagingDir, snapshotPath)
- })
- }
- }
-
- let current = 0
- for (const job of tableJobs) {
- current++
- emitBackupProgress({
- phase: 'exporting',
- message: '正在导出数据库快照',
- current,
- total: tableJobs.length,
- detail: `${job.db.kind}:${job.table}`
- })
- const exported = await wcdbService.exportTableSnapshot(job.db.kind, job.db.dbPath, job.table, job.outputPath)
- if (!exported.success) {
- throw new Error(`${job.db.kind}:${job.table} 导出失败:${exported.error || 'unknown'}`)
- }
- const dbEntry = manifest.databases.find(item => item.id === job.db.id)
- dbEntry?.tables.push({
- name: job.table,
- snapshotPath: job.snapshotPath,
- rows: exported.rows || 0,
- columns: exported.columns || 0,
- schemaSql: job.schemaSql
- })
- }
-
- if (options.includeImages === true) {
- await this.collectImageResources(
- { wxid: connected.wxid, dbStorage: connected.dbStorage },
- stagingDir,
- manifest
- )
- }
- if (options.includeVideos === true) {
- await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'video')
- }
- if (options.includeFiles === true) {
- await this.collectPlainResources({ dbStorage: connected.dbStorage }, stagingDir, manifest, 'file')
- }
-
- await writeFile(join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
- mkdirSync(dirname(outputPath), { recursive: true })
- const archiveFiles = await this.listFilesForArchive(stagingDir)
- const shouldCompress = !hasResourceOptions(options)
- let packed = 0
- const emitPackingProgress = createThrottledProgressEmitter(150)
- emitBackupProgress({ phase: 'packing', message: '正在生成备份包', current: 0, total: archiveFiles.length })
- await tar.c({
- gzip: shouldCompress ? { level: 1 } : false,
- cwd: stagingDir,
- file: outputPath,
- portable: true,
- noMtime: true,
- sync: false,
- onWriteEntry: (entry: any) => {
- packed += 1
- emitPackingProgress({
- phase: 'packing',
- message: '正在写入备份包',
- current: Math.min(packed, archiveFiles.length),
- total: archiveFiles.length,
- detail: String(entry?.path || entry || '')
- })
- }
- } as any, archiveFiles)
- emitBackupProgress({
- phase: 'packing',
- message: '正在写入备份包',
- current: archiveFiles.length,
- total: archiveFiles.length
- })
- emitBackupProgress({ phase: 'done', message: '备份完成', current: tableJobs.length, total: tableJobs.length })
- return { success: true, filePath: outputPath, manifest }
- } catch (e) {
- const error = e instanceof Error ? e.message : String(e)
- emitBackupProgress({ phase: 'failed', message: error })
- return { success: false, error }
- } finally {
- if (stagingDir) {
- try { rmSync(stagingDir, { recursive: true, force: true }) } catch {}
- }
- }
- }
-
- async inspectBackup(archivePath: string): Promise<{ success: boolean; manifest?: BackupManifest; error?: string }> {
- let extractDir = ''
- try {
- emitBackupProgress({ phase: 'inspecting', message: '正在读取备份包' })
- extractDir = await this.createTempDir('weflow-backup-inspect-')
- await tar.x({
- file: archivePath,
- cwd: extractDir,
- filter: (entryPath: string) => entryPath.replace(/\\/g, '/') === 'manifest.json'
- } as any)
- const manifestPath = join(extractDir, 'manifest.json')
- if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
- const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
- if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
- emitBackupProgress({ phase: 'failed', message: '不支持的备份包格式' })
- return { success: false, error: '不支持的备份包格式' }
- }
- emitBackupProgress({ phase: 'done', message: '备份包已读取' })
- return { success: true, manifest }
- } catch (e) {
- emitBackupProgress({ phase: 'failed', message: e instanceof Error ? e.message : String(e) })
- return { success: false, error: e instanceof Error ? e.message : String(e) }
- } finally {
- if (extractDir) {
- try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
- }
- }
- }
-
- private async streamRestoreArchive(
- archivePath: string,
- extractDir: string,
- manifest: BackupManifest,
- connected: { dbStorage: string; wxid?: string },
- startCurrent: number,
- total: number
- ): Promise<{ current: number; skipped: number }> {
- const snapshotPaths = new Set()
- for (const db of manifest.databases || []) {
- for (const table of db.tables || []) {
- const path = normalizeArchivePath(table.snapshotPath)
- if (path) snapshotPaths.add(path)
- }
- }
-
- const imageByPath = new Map()
- for (const image of manifest.resources?.images || []) {
- const path = normalizeArchivePath(image.archivePath)
- if (path) imageByPath.set(path, image)
- }
-
- const plainByPath = new Map()
- for (const resource of [
- ...(manifest.resources?.videos || []),
- ...(manifest.resources?.files || [])
- ]) {
- const path = normalizeArchivePath(resource.archivePath)
- if (path) plainByPath.set(path, resource)
- }
-
- const accountDir = dirname(connected.dbStorage)
- let current = startCurrent
- let skipped = 0
- const pending: Promise[] = []
- const emitRestoreProgress = createThrottledProgressEmitter(160)
- await tar.t({
- file: archivePath,
- onReadEntry: (entry: any) => {
- const entryPath = normalizeArchivePath(entry.path)
- if (snapshotPaths.has(entryPath)) {
- const outputPath = this.resolveStagingPath(extractDir, entryPath)
- if (!outputPath) {
- entry.resume()
- return
- }
- pending.push(this.writeTarEntryToFile(entry, outputPath))
- return
- }
-
- const image = imageByPath.get(entryPath)
- if (image) {
- const targetPath = this.resolveTargetResourcePath(accountDir, image.targetRelativePath)
- if (!targetPath) {
- skipped += 1
- entry.resume()
- return
- }
- current += 1
- emitRestoreProgress({
- phase: 'restoring',
- message: '正在写回图片资源',
- current,
- total,
- detail: image.md5 || image.targetRelativePath
- })
- if (existsSync(targetPath)) {
- skipped += 1
- entry.resume()
- return
- }
- pending.push(this.writeTarEntryToFile(entry, targetPath))
- return
- }
-
- const resource = plainByPath.get(entryPath)
- if (resource) {
- const targetPath = this.resolveTargetResourcePath(accountDir, resource.targetRelativePath)
- current += 1
- emitRestoreProgress({
- phase: 'restoring',
- message: resource.kind === 'video' ? '正在写回视频资源' : '正在写回文件资源',
- current,
- total,
- detail: resource.targetRelativePath
- })
- if (!targetPath || existsSync(targetPath)) {
- skipped += 1
- entry.resume()
- return
- }
- pending.push(this.writeTarEntryToFile(entry, targetPath))
- return
- }
-
- entry.resume()
- }
- } as any)
-
- await Promise.all(pending)
- return { current, skipped }
- }
-
- async restoreBackup(archivePath: string): Promise<{ success: boolean; inserted?: number; ignored?: number; skipped?: number; error?: string }> {
- let extractDir = ''
- try {
- emitBackupProgress({ phase: 'inspecting', message: '正在读取备份信息' })
- extractDir = await this.createTempDir('weflow-backup-restore-')
- await tar.x({
- file: archivePath,
- cwd: extractDir,
- filter: (entryPath: string) => normalizeArchivePath(entryPath) === 'manifest.json'
- } as any)
- const manifestPath = join(extractDir, 'manifest.json')
- if (!existsSync(manifestPath)) return { success: false, error: '备份包缺少 manifest.json' }
- const manifest = JSON.parse(await readFileAsync(manifestPath, 'utf8')) as BackupManifest
- if (manifest?.type !== 'weflow-db-snapshots' || manifest.version !== 1) {
- return { success: false, error: '不支持的备份包格式' }
- }
- const targetWxid = String(manifest.source?.wxid || '').trim()
- if (!targetWxid) return { success: false, error: '备份包缺少来源账号 wxid,无法定位目标账号目录' }
-
- emitBackupProgress({ phase: 'preparing', message: '正在连接目标数据库', detail: targetWxid })
- const connected = await this.ensureConnected(targetWxid)
- if (!connected.success || !connected.dbStorage) return { success: false, error: connected.error || '数据库未连接' }
-
- const tableJobs = manifest.databases.flatMap(db => db.tables.map(table => ({ db, table })))
- const imageJobs = manifest.resources?.images || []
- const plainResourceJobs = [
- ...(manifest.resources?.videos || []),
- ...(manifest.resources?.files || [])
- ]
- const totalRestoreJobs = tableJobs.length + imageJobs.length + plainResourceJobs.length
- let inserted = 0
- let ignored = 0
- let skipped = 0
- let current = 0
- if (imageJobs.length > 0 || plainResourceJobs.length > 0 || tableJobs.length > 0) {
- emitBackupProgress({
- phase: 'inspecting',
- message: '正在按需读取备份包',
- current: 0,
- total: totalRestoreJobs,
- detail: archivePath
- })
- const streamed = await this.streamRestoreArchive(
- archivePath,
- extractDir,
- manifest,
- { dbStorage: connected.dbStorage, wxid: connected.wxid },
- 0,
- totalRestoreJobs
- )
- current = streamed.current
- skipped += streamed.skipped
- }
-
- for (const job of tableJobs) {
- current++
- const targetDbPath = this.resolveRestoreTargetDbPath(connected.dbStorage, job.db)
- if (targetDbPath === null) {
- skipped++
- continue
- }
- if (!job.table.schemaSql) {
- skipped++
- continue
- }
-
- emitBackupProgress({
- phase: 'restoring',
- message: '正在通过 WCDB 写入数据库',
- current,
- total: totalRestoreJobs,
- detail: `${job.db.kind}:${job.table.name}`
- })
- const inputPath = this.resolveExtractedPath(extractDir, job.table.snapshotPath)
- if (!inputPath || !existsSync(inputPath)) {
- skipped++
- continue
- }
- mkdirSync(dirname(targetDbPath), { recursive: true })
- const restored = await wcdbService.importTableSnapshotWithSchema(
- job.db.kind,
- targetDbPath,
- job.table.name,
- inputPath,
- job.table.schemaSql
- )
- if (!restored.success) {
- skipped++
- continue
- }
- inserted += restored.inserted || 0
- ignored += restored.ignored || 0
- if (current % 4 === 0) await delay()
- }
-
- emitBackupProgress({ phase: 'done', message: '载入完成', current: totalRestoreJobs, total: totalRestoreJobs })
- return { success: true, inserted, ignored, skipped }
- } catch (e) {
- const error = e instanceof Error ? e.message : String(e)
- emitBackupProgress({ phase: 'failed', message: error })
- return { success: false, error }
- } finally {
- if (extractDir) {
- try { rmSync(extractDir, { recursive: true, force: true }) } catch {}
- }
- }
- }
-}
-
-export const backupService = new BackupService()
diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts
deleted file mode 100644
index 2ee481e..0000000
--- a/electron/services/bizService.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-import { join } from 'path'
-import { readdirSync, existsSync } from 'fs'
-import { wcdbService } from './wcdbService'
-import { ConfigService } from './config'
-import { chatService, Message } from './chatService'
-import { ipcMain } from 'electron'
-import { createHash } from 'crypto'
-
-export interface BizAccount {
- username: string
- name: string
- avatar: string
- type: number
- last_time: number
- formatted_last_time: string
- unread_count?: number
-}
-
-export interface BizMessage {
- local_id: number
- create_time: number
- title: string
- des: string
- url: string
- cover: string
- content_list: any[]
-}
-
-export interface BizPayRecord {
- local_id: number
- create_time: number
- title: string
- description: string
- merchant_name: string
- merchant_icon: string
- timestamp: number
- formatted_time: string
-}
-
-export class BizService {
- private configService: ConfigService
-
- constructor() {
- this.configService = new ConfigService()
- }
-
- private extractXmlValue(xml: string, tagName: string): string {
- const regex = new RegExp(`<${tagName}>([\\s\\S]*?)${tagName}>`, 'i')
- const match = regex.exec(xml)
- if (match) {
- return match[1].replace(//g, '').trim()
- }
- return ''
- }
-
- private parseBizContentList(xmlStr: string): any[] {
- if (!xmlStr) return []
- const contentList: any[] = []
- try {
- const itemRegex = /- ([\s\S]*?)<\/item>/gi
- let match: RegExpExecArray | null
- while ((match = itemRegex.exec(xmlStr)) !== null) {
- const itemXml = match[1]
- const itemStruct = {
- title: this.extractXmlValue(itemXml, 'title'),
- url: this.extractXmlValue(itemXml, 'url'),
- cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
- summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
- }
- if (itemStruct.title) contentList.push(itemStruct)
- }
- } catch (e) { }
- return contentList
- }
-
- private parsePayXml(xmlStr: string): any {
- if (!xmlStr) return null
- try {
- const title = this.extractXmlValue(xmlStr, 'title')
- const description = this.extractXmlValue(xmlStr, 'des')
- const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
- const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
- const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
- if (!title && !description) return null
- return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
- } catch (e) { return null }
- }
-
- async listAccounts(account?: string): Promise {
- try {
- // 1. 获取公众号联系人列表
- const contactsResult = await chatService.getContacts({ lite: true })
- if (!contactsResult.success || !contactsResult.contacts) return []
-
- const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
- const usernames = officialContacts.map(c => c.username)
-
- // 获取头像和昵称等补充信息
- const enrichment = await chatService.enrichSessionsContactInfo(usernames)
- const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
-
- const root = this.configService.get('dbPath')
- const myWxid = this.configService.getMyWxidCleaned()
- const accountWxid = account || myWxid
- if (!root || !accountWxid) return []
-
- const bizLatestTime: Record = {}
- const bizUnreadCount: Record = {}
-
- try {
- const sessionsRes = await chatService.getSessions()
- if (sessionsRes.success && sessionsRes.sessions) {
- for (const session of sessionsRes.sessions) {
- const uname = session.username || session.strUsrName || session.userName || session.id
- // 适配日志中发现的字段,注意转为整型数字
- const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
- const time = parseInt(timeStr.toString(), 10)
-
- if (usernames.includes(uname) && time > 0) {
- bizLatestTime[uname] = time
- }
- if (usernames.includes(uname)) {
- const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
- bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
- }
- }
- }
- } catch (e) {
- console.error('获取 Sessions 失败:', e)
- }
-
- // 3. 格式化时间显示
- const formatBizTime = (ts: number) => {
- if (!ts) return ''
- const date = new Date(ts * 1000)
- const now = new Date()
- const isToday = date.toDateString() === now.toDateString()
- if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
-
- const yesterday = new Date(now)
- yesterday.setDate(now.getDate() - 1)
- if (date.toDateString() === yesterday.toDateString()) return '昨天'
-
- const isThisYear = date.getFullYear() === now.getFullYear()
- if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
-
- return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
- }
-
- // 4. 组装数据
- const result: BizAccount[] = officialContacts.map(contact => {
- const uname = contact.username
- const info = contactInfoMap[uname]
- const lastTime = bizLatestTime[uname] || 0
- return {
- username: uname,
- name: info?.displayName || contact.displayName || uname,
- avatar: info?.avatarUrl || '',
- type: 0,
- last_time: lastTime,
- formatted_last_time: formatBizTime(lastTime),
- unread_count: bizUnreadCount[uname] || 0
- }
- })
-
- // 5. 补充公众号类型 (订阅号/服务号)
- const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
- if (existsSync(contactDbPath)) {
- const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
- if (bizInfoRes.success && bizInfoRes.rows) {
- const typeMap: Record = {}
- for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
- for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
- }
- }
-
- // 6. 排序输出
- return result
- .filter(acc => !acc.name.includes('广告'))
- .sort((a, b) => {
- if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
- if (b.username === 'gh_3dfda90e39d6') return 1
- return b.last_time - a.last_time // 按最新时间降序排列
- })
- } catch (e) {
- console.error('获取账号列表发生错误:', e)
- return []
- }
- }
-
- async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise {
- try {
- // 仅保留核心路径:利用 chatService 的自动路由能力
- const res = await chatService.getMessages(username, offset, limit)
- if (!res.success || !res.messages) return []
-
- return res.messages.map(msg => {
- const bizMsg: BizMessage = {
- local_id: msg.localId,
- create_time: msg.createTime,
- title: msg.linkTitle || msg.parsedContent || '',
- des: msg.appMsgDesc || '',
- url: msg.linkUrl || '',
- cover: msg.linkThumb || msg.appMsgThumbUrl || '',
- content_list: []
- }
- if (msg.rawContent) {
- bizMsg.content_list = this.parseBizContentList(msg.rawContent)
- if (bizMsg.content_list.length > 0 && !bizMsg.title) {
- bizMsg.title = bizMsg.content_list[0].title
- bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
- }
- }
- return bizMsg
- })
- } catch (e) { return [] }
- }
-
- async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise {
- const username = 'gh_3dfda90e39d6'
- try {
- const res = await chatService.getMessages(username, offset, limit)
- if (!res.success || !res.messages) return []
-
- const records: BizPayRecord[] = []
- for (const msg of res.messages) {
- if (!msg.rawContent) continue
- const parsedData = this.parsePayXml(msg.rawContent)
- if (parsedData) {
- records.push({
- local_id: msg.localId,
- create_time: msg.createTime,
- ...parsedData,
- timestamp: parsedData.timestamp || msg.createTime,
- formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
- })
- }
- }
- return records
- } catch (e) { return [] }
- }
-
- registerHandlers() {
- ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
- ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
- ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
- }
-}
-
-export const bizService = new BizService()
diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts
deleted file mode 100644
index 6c393a0..0000000
--- a/electron/services/chatService.ts
+++ /dev/null
@@ -1,11897 +0,0 @@
-import { join, dirname, basename, extname } from 'path'
-import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs'
-import { createRequire } from 'module'
-import * as path from 'path'
-import * as fs from 'fs'
-import * as https from 'https'
-import * as http from 'http'
-import * as fzstd from 'fzstd'
-import * as crypto from 'crypto'
-import { app, BrowserWindow, dialog } from 'electron'
-import { ConfigService } from './config'
-import { wcdbService } from './wcdbService'
-import { MessageCacheService } from './messageCacheService'
-import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
-import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService'
-import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService'
-import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
-import { voiceTranscribeService } from './voiceTranscribeService'
-import { ImageDecryptService } from './imageDecryptService'
-import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
-import { LRUCache } from '../utils/LRUCache.js'
-
-export interface ChatSession {
- username: string
- type: number
- unreadCount: number
- summary: string
- sortTimestamp: number // 用于排序
- lastTimestamp: number // 用于显示时间
- lastMsgType: number
- messageCountHint?: number
- displayName?: string
- avatarUrl?: string
- lastMsgSender?: string
- lastSenderDisplayName?: string
- selfWxid?: string
- isFolded?: boolean // 是否已折叠进"折叠的群聊"
- isMuted?: boolean // 是否开启免打扰
-}
-
-export interface Message {
- messageKey: string
- localId: number
- serverId: number
- serverIdRaw?: string
- localType: number
- createTime: number
- sortSeq: number
- isSend: number | null
- senderUsername: string | null
- parsedContent: string
- rawContent: string
- content?: string // 原始XML内容(与rawContent相同,供前端使用)
- // 表情包相关
- emojiCdnUrl?: string
- emojiMd5?: string
- emojiLocalPath?: string // 本地缓存 castle 路径
- emojiThumbUrl?: string
- emojiEncryptUrl?: string
- emojiAesKey?: string
- // 引用消息相关
- quotedContent?: string
- quotedSender?: string
- // 图片/视频相关
- imageMd5?: string
- imageDatName?: string
- videoMd5?: string
- aesKey?: string
- encrypVer?: number
- cdnThumbUrl?: string
- voiceDurationSeconds?: number
- // Type 49 细分字段
- linkTitle?: string // 链接/文件标题
- linkUrl?: string // 链接 URL
- linkThumb?: string // 链接缩略图
- fileName?: string // 文件名
- fileSize?: number // 文件大小
- fileExt?: string // 文件扩展名
- fileMd5?: string // 文件 MD5
- xmlType?: string // XML 中的 type 字段
- appMsgKind?: string // 归一化 appmsg 类型
- appMsgDesc?: string
- appMsgAppName?: string
- appMsgSourceName?: string
- appMsgSourceUsername?: string
- appMsgThumbUrl?: string
- appMsgMusicUrl?: string
- appMsgDataUrl?: string
- appMsgLocationLabel?: string
- finderNickname?: string
- finderUsername?: string
- finderCoverUrl?: string
- finderAvatar?: string
- finderDuration?: number
- // 位置消息
- locationLat?: number
- locationLng?: number
- locationPoiname?: string
- locationLabel?: string
- // 音乐消息
- musicAlbumUrl?: string
- musicUrl?: string
- // 礼物消息
- giftImageUrl?: string
- giftWish?: string
- giftPrice?: string
- // 名片消息
- cardUsername?: string // 名片的微信ID
- cardNickname?: string // 名片的昵称
- cardAvatarUrl?: string // 名片头像 URL
- // 转账消息
- transferPayerUsername?: string // 转账付款人
- transferReceiverUsername?: string // 转账收款人
- // 聊天记录
- chatRecordTitle?: string // 聊天记录标题
- chatRecordList?: Array<{
- datatype: number
- sourcename: string
- sourcetime: string
- sourceheadurl?: string
- datadesc?: string
- datatitle?: string
- fileext?: string
- datasize?: number
- messageuuid?: string
- dataurl?: string
- datathumburl?: string
- datacdnurl?: string
- cdndatakey?: string
- cdnthumbkey?: string
- aeskey?: string
- md5?: string
- fullmd5?: string
- thumbfullmd5?: string
- srcMsgLocalid?: number
- imgheight?: number
- imgwidth?: number
- duration?: number
- chatRecordTitle?: string
- chatRecordDesc?: string
- chatRecordList?: any[]
- }>
- _db_path?: string // 内部字段:记录消息所属数据库路径
-}
-
-type ResourceMessageType = 'image' | 'video' | 'voice' | 'file'
-
-interface ResourceMessageItem extends Message {
- sessionId: string
- sessionDisplayName?: string
- resourceType: ResourceMessageType
-}
-
-export interface Contact {
- username: string
- alias: string
- remark: string
- nickName: string
-}
-
-export interface ContactInfo {
- username: string
- displayName: string
- remark?: string
- nickname?: string
- alias?: string
- labels?: string[]
- detailDescription?: string
- region?: string
- avatarUrl?: string
- type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
-}
-
-interface GetContactsOptions {
- lite?: boolean
-}
-
-interface ExportSessionStats {
- totalMessages: number
- voiceMessages: number
- imageMessages: number
- videoMessages: number
- emojiMessages: number
- transferMessages: number
- redPacketMessages: number
- callMessages: number
- firstTimestamp?: number
- lastTimestamp?: number
- privateMutualGroups?: number
- groupMemberCount?: number
- groupMyMessages?: number
- groupActiveSpeakers?: number
- groupMutualFriends?: number
-}
-
-interface ExportSessionStatsOptions {
- includeRelations?: boolean
- forceRefresh?: boolean
- allowStaleCache?: boolean
- preferAccurateSpecialTypes?: boolean
- cacheOnly?: boolean
- beginTimestamp?: number
- endTimestamp?: number
-}
-
-interface ExportSessionStatsCacheMeta {
- updatedAt: number
- stale: boolean
- includeRelations: boolean
- source: 'memory' | 'disk' | 'fresh'
-}
-
-interface ExportTabCounts {
- private: number
- group: number
- official: number
- former_friend: number
-}
-
-interface SessionDetailFast {
- wxid: string
- displayName: string
- remark?: string
- nickName?: string
- alias?: string
- avatarUrl?: string
- messageCount: number
-}
-
-interface SessionDetailExtra {
- firstMessageTime?: number
- latestMessageTime?: number
- messageTables: { dbName: string; tableName: string; count: number }[]
-}
-
-type SessionDetail = SessionDetailFast & SessionDetailExtra
-
-interface SyntheticUnreadState {
- readTimestamp: number
- scannedTimestamp: number
- latestTimestamp: number
- unreadCount: number
- summaryTimestamp?: number
- summary?: string
- lastMsgType?: number
-}
-
-interface MyFootprintSummary {
- private_inbound_people: number
- private_replied_people: number
- private_outbound_people: number
- private_reply_rate: number
- mention_count: number
- mention_group_count: number
-}
-
-interface MyFootprintPrivateSession {
- session_id: string
- incoming_count: number
- outgoing_count: number
- replied: boolean
- first_incoming_ts: number
- first_reply_ts: number
- latest_ts: number
- anchor_local_id: number
- anchor_create_time: number
- displayName?: string
- avatarUrl?: string
-}
-
-interface MyFootprintPrivateSegment {
- session_id: string
- segment_index: number
- start_ts: number
- end_ts: number
- duration_sec: number
- incoming_count: number
- outgoing_count: number
- message_count: number
- replied: boolean
- first_incoming_ts: number
- first_reply_ts: number
- latest_ts: number
- anchor_local_id: number
- anchor_create_time: number
- displayName?: string
- avatarUrl?: string
-}
-
-interface MyFootprintMentionItem {
- session_id: string
- local_id: number
- create_time: number
- sender_username: string
- message_content: string
- source: string
- sessionDisplayName?: string
- senderDisplayName?: string
- senderAvatarUrl?: string
-}
-
-interface MyFootprintMentionGroup {
- session_id: string
- count: number
- latest_ts: number
- displayName?: string
- avatarUrl?: string
-}
-
-interface MyFootprintDiagnostics {
- truncated: boolean
- scanned_dbs: number
- elapsed_ms: number
- mention_truncated?: boolean
- private_truncated?: boolean
- native_ms?: number
- source_filter_ms?: number
- fallback_ms?: number
- enrich_ms?: number
- pipeline_ms?: number
- fallback_used?: boolean
- private_limit_effective?: number
- mention_candidate_limit?: number
- native_mention_candidates?: number
- source_filtered_mentions?: number
- private_session_count?: number
- group_session_count?: number
- native_passes?: number
- native_group_chunks?: number
-}
-
-interface MyFootprintData {
- summary: MyFootprintSummary
- private_sessions: MyFootprintPrivateSession[]
- private_segments: MyFootprintPrivateSegment[]
- mentions: MyFootprintMentionItem[]
- mention_groups: MyFootprintMentionGroup[]
- diagnostics: MyFootprintDiagnostics
-}
-
-// 表情包缓存
-const emojiCache: Map = new Map()
-const emojiDownloading: Map> = new Map()
-const FRIEND_EXCLUDE_USERNAMES = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
-
-class ChatService {
- private configService: ConfigService
- private runtimeConfig?: { dbPath?: string; decryptKey?: string; myWxid?: string }
- private connected = false
- private readonly dbMonitorListeners = new Set<(type: string, json: string) => void>()
- private messageCursors: Map = new Map()
- private messageCursorMutex: boolean = false
- private readonly messageBatchDefault = 50
- private readonly messageCursorSessionLimit = 8
- private avatarCache: Map
- private readonly avatarCacheTtlMs = 10 * 60 * 1000
- private readonly defaultV1AesKey = 'cfcd208495d565ef'
- private readonly contactCacheService: ContactCacheService
- private readonly messageCacheService: MessageCacheService
- private readonly sessionStatsCacheService: SessionStatsCacheService
- private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService
- private readonly imageDecryptService: ImageDecryptService
- private voiceWavCache: LRUCache
- private voiceTranscriptCache: LRUCache
- private voiceTranscriptPending = new Map>()
- private transcriptCacheLoaded = false
- private transcriptCacheDirty = false
- private transcriptFlushTimer: ReturnType | null = null
- private mediaDbsCache: string[] | null = null
- private mediaDbsCacheTime = 0
- private readonly mediaDbsCacheTtl = 300000 // 5分钟
- private readonly voiceWavCacheMaxEntries = 50
- // 缓存 media.db 的表结构信息
- private mediaDbSchemaCache = new Map()
- // 缓存会话表信息,避免每次查询
- private sessionTablesCache = new Map; updatedAt: number }>()
- private messageTableColumnsCache = new Map; updatedAt: number }>()
- private messageName2IdTableCache = new Map()
- private messageSenderIdCache = new Map()
- private readonly sessionTablesCacheTtl = 300000 // 5分钟
- private readonly messageTableColumnsCacheTtlMs = 30 * 60 * 1000
- private messageDbCountSnapshotCache: {
- dbPaths: string[]
- dbSignature: string
- updatedAt: number
- } | null = null
- private readonly messageDbCountSnapshotCacheTtlMs = 8000
- private sessionMessageCountCache = new Map()
- private sessionMessageCountHintCache = new Map()
- private syntheticUnreadState = new Map()
- private sessionMessageCountBatchCache: {
- dbSignature: string
- sessionIdsKey: string
- counts: Record
- updatedAt: number
- } | null = null
- private sessionMessageCountCacheScope = ''
- private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000
- private readonly sessionMessageCountBatchCacheTtlMs = 5 * 60 * 1000
- private sessionDetailFastCache = new Map()
- private sessionDetailExtraCache = new Map()
- private readonly sessionDetailFastCacheTtlMs = 60 * 1000
- private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000
- private sessionStatusCache = new Map()
- private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000
- private sessionStatsCacheScope = ''
- private sessionStatsMemoryCache = new Map()
- private sessionStatsPendingBasic = new Map>()
- private sessionStatsPendingFull = new Map>()
- private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null
- private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000
- private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
- private groupMyMessageCountCacheScope = ''
- private groupMyMessageCountMemoryCache = new Map()
- private initFailureDialogShown = false
- private readonly contactExtendedFieldCandidates = [
- 'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList',
- 'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign',
- 'country', 'province', 'city', 'region',
- 'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo',
- 'extra_buffer', 'extraBuffer'
- ]
- private readonly contactExtendedFieldCandidateSet = new Set(this.contactExtendedFieldCandidates.map((name) => name.toLowerCase()))
- private contactExtendedSelectableColumns: string[] | null = null
- private contactLabelNameMapCache: Map | null = null
- private contactLabelNameMapCacheAt = 0
- private readonly visibilityAnomalyLogWindowMs = 30000
- private readonly visibilityAnomalyLogBurst = 3
- private visibilityAnomalyLogState = new Map()
- private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
- private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
- private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
- private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
- private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
- private readonly slowGetContactsLogThresholdMs = 1200
-
- constructor() {
- this.configService = new ConfigService()
- this.contactCacheService = new ContactCacheService(this.configService.getCacheBasePath())
- const persisted = this.contactCacheService.getAllEntries()
- this.avatarCache = new Map(Object.entries(persisted))
- this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
- this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath())
- this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath())
- this.imageDecryptService = new ImageDecryptService()
- // 初始化LRU缓存,限制大小防止内存泄漏
- this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
- this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
- }
-
- setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string; resourcesPath?: string; appPath?: string; isPackaged?: boolean }): void {
- this.runtimeConfig = config
- }
-
- /**
- * 清理账号目录名
- */
- private cleanAccountDirName(dirName: string): string {
- const trimmed = dirName.trim()
- if (!trimmed) return trimmed
-
- if (trimmed.toLowerCase().startsWith('wxid_')) {
- const match = trimmed.match(/^(wxid_[^_]+)/i)
- if (match) return match[1]
- return trimmed
- }
-
- const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
- const cleaned = suffixMatch ? suffixMatch[1] : trimmed
-
- return cleaned
- }
-
- /**
- * 判断头像 URL 是否可用,过滤历史缓存里的错误 hex 数据。
- */
- private isValidAvatarUrl(avatarUrl?: string): avatarUrl is string {
- const normalized = String(avatarUrl || '').trim()
- if (!normalized) return false
- const normalizedLower = normalized.toLowerCase()
- if (normalizedLower.includes('base64,ffd8')) return false
- if (normalizedLower.startsWith('ffd8')) return false
- return true
- }
-
- private extractErrorCode(message?: string | null): number | null {
- const text = String(message || '').trim()
- if (!text) return null
- const match = text.match(/(?:错误码\s*[::]\s*|\()(-?\d{2,6})(?:\)|\b)/)
- if (!match) return null
- const parsed = Number(match[1])
- return Number.isFinite(parsed) ? parsed : null
- }
-
- private toCodeOnlyMessage(rawMessage?: string | null, fallbackCode = -3999): string {
- const code = this.extractErrorCode(rawMessage) ?? fallbackCode
- return `错误码: ${code}`
- }
-
- private async maybeShowInitFailureDialog(errorMessage: string): Promise {
- if (!app.isPackaged) return
- if (this.initFailureDialogShown) return
-
- const code = this.extractErrorCode(errorMessage)
- if (code === null) return
- const isSecurityCode =
- code === -101 ||
- code === -102 ||
- code === -2299 ||
- code === -2301 ||
- code === -2302 ||
- code === -1006 ||
- (code <= -2201 && code >= -2212)
- if (!isSecurityCode) return
-
- this.initFailureDialogShown = true
- const detail = [
- `错误码: ${code}`
- ].join('\n')
-
- try {
- await dialog.showMessageBox({
- type: 'error',
- title: 'WeFlow 启动失败',
- message: '启动失败,请反馈错误码。',
- detail,
- buttons: ['确定'],
- noLink: true
- })
- } catch {
- // 弹窗失败不阻断主流程
- }
- }
-
- /**
- * 连接数据库
- */
- async connect(): Promise<{ success: boolean; error?: string }> {
- try {
- const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
- const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
- const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
- if (!wxid) {
- return { success: false, error: '请先在设置页面配置微信ID' }
- }
- if (!dbPath) {
- return { success: false, error: '请先在设置页面配置数据库路径' }
- }
- if (!decryptKey) {
- return { success: false, error: '请先在设置页面配置解密密钥' }
- }
-
- if (this.connected && wcdbService.isReady()) {
- return { success: true }
- }
-
- // 使用 ConfigService 统一解析账号目录
- const accountDir = this.configService.getAccountDir(dbPath, wxid)
- if (!accountDir) {
- return { success: false, error: '未找到账号目录,请检查数据库路径和微信ID配置' }
- }
-
- const openOk = await wcdbService.open(accountDir, decryptKey)
- if (!openOk) {
- const detailedError = this.toCodeOnlyMessage(await wcdbService.getLastInitError())
- await this.maybeShowInitFailureDialog(detailedError)
- return { success: false, error: detailedError }
- }
-
- this.connected = true
-
- // 设置数据库监控
- this.setupDbMonitor()
-
- // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
- this.warmupMediaDbsCache()
-
- return { success: true }
- } catch (e) {
- console.error('ChatService: 连接数据库失败:', e)
- return { success: false, error: this.toCodeOnlyMessage(String(e), -3998) }
- }
- }
-
- private monitorSetup = false
-
- addDbMonitorListener(listener: (type: string, json: string) => void): () => void {
- this.dbMonitorListeners.add(listener)
- return () => {
- this.dbMonitorListeners.delete(listener)
- }
- }
-
- private setupDbMonitor() {
- if (this.monitorSetup) return
- this.monitorSetup = true
-
- // 使用 C++数据服务内部的文件监控 (ReadDirectoryChangesW)
- // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
- wcdbService.setMonitor((type, json) => {
- this.handleSessionStatsMonitorChange(type, json)
- for (const listener of this.dbMonitorListeners) {
- try {
- listener(type, json)
- } catch (error) {
- console.error('[ChatService] 数据库监听回调失败:', error)
- }
- }
- const windows = BrowserWindow.getAllWindows()
- // 广播给所有渲染进程窗口
- windows.forEach((win) => {
- if (!win.isDestroyed()) {
- win.webContents.send('wcdb-change', { type, json })
- }
- })
- })
- }
-
- /**
- * 预热 media 数据库列表缓存(后台异步执行)
- */
- private async warmupMediaDbsCache(): Promise {
- try {
- const result = await wcdbService.listMediaDbs()
- if (result.success && result.data) {
- this.mediaDbsCache = result.data as string[]
- this.mediaDbsCacheTime = Date.now()
- }
- } catch (e) {
- // 静默失败,不影响主流程
- }
- }
-
- async warmupMessageDbSnapshot(): Promise<{ success: boolean; messageDbCount?: number; mediaDbCount?: number; error?: string }> {
- try {
- const connectResult = await this.ensureConnected()
- if (!connectResult.success) {
- return { success: false, error: connectResult.error || '数据库未连接' }
- }
-
- const [messageSnapshot, mediaResult] = await Promise.all([
- this.getMessageDbCountSnapshot(true),
- wcdbService.listMediaDbs()
- ])
-
- let messageDbCount = 0
- if (messageSnapshot.success && Array.isArray(messageSnapshot.dbPaths)) {
- messageDbCount = messageSnapshot.dbPaths.length
- }
-
- let mediaDbCount = 0
- if (mediaResult.success && Array.isArray(mediaResult.data)) {
- this.mediaDbsCache = [...mediaResult.data]
- this.mediaDbsCacheTime = Date.now()
- mediaDbCount = mediaResult.data.length
- }
-
- if (!messageSnapshot.success && !mediaResult.success) {
- return {
- success: false,
- error: messageSnapshot.error || mediaResult.error || '初始化消息库索引失败'
- }
- }
-
- return { success: true, messageDbCount, mediaDbCount }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
- if (this.connected && wcdbService.isReady()) {
- return { success: true }
- }
- if (!wcdbService.isReady()) {
- this.monitorSetup = false
- }
- const result = await this.connect()
- if (!result.success) {
- this.connected = false
- return { success: false, error: result.error }
- }
- return { success: true }
- }
-
- /**
- * 关闭数据库连接
- */
- private async closeMessageCursorBySession(sessionId: string): Promise {
- const state = this.messageCursors.get(sessionId)
- if (!state) return
- try {
- await wcdbService.closeMessageCursor(state.cursor)
- } catch (error) {
- console.warn(`[ChatService] 关闭消息游标失败: ${sessionId}`, error)
- } finally {
- this.messageCursors.delete(sessionId)
- }
- }
-
- private async trimMessageCursorStates(activeSessionId: string): Promise {
- if (this.messageCursors.size <= this.messageCursorSessionLimit) return
- for (const [sessionId] of this.messageCursors) {
- if (this.messageCursors.size <= this.messageCursorSessionLimit) break
- if (sessionId === activeSessionId) continue
- await this.closeMessageCursorBySession(sessionId)
- }
- }
-
- close(): void {
- try {
- for (const state of this.messageCursors.values()) {
- wcdbService.closeMessageCursor(state.cursor)
- }
- this.messageCursors.clear()
- wcdbService.close()
- } catch (e) {
- console.error('ChatService: 关闭数据库失败:', e)
- }
- this.connected = false
- this.monitorSetup = false
- }
-
- /**
- * 修改消息内容
- */
- async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
- try {
- const connectResult = await this.ensureConnected()
- if (!connectResult.success) return { success: false, error: connectResult.error }
- return await wcdbService.updateMessage(sessionId, localId, createTime, newContent)
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- /**
- * 删除消息
- */
- async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
- try {
- const connectResult = await this.ensureConnected()
- if (!connectResult.success) return { success: false, error: connectResult.error }
- return await wcdbService.deleteMessage(sessionId, localId, createTime, dbPathHint)
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async checkAntiRevokeTriggers(sessionIds: string[]): Promise<{
- success: boolean
- rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
- error?: string
- }> {
- try {
- const connectResult = await this.ensureConnected()
- if (!connectResult.success) return { success: false, error: connectResult.error }
- const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
- const result = validIds.length > 0
- ? await wcdbService.checkMessageAntiRevokeTriggers(validIds)
- : { success: true, rows: [] }
- if (!result.success) return result
- return { success: true, rows: [...(result.rows || []), ...invalidRows] }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async installAntiRevokeTriggers(sessionIds: string[]): Promise<{
- success: boolean
- rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
- error?: string
- }> {
- try {
- const connectResult = await this.ensureConnected()
- if (!connectResult.success) return { success: false, error: connectResult.error }
- const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
- const result = validIds.length > 0
- ? await wcdbService.installMessageAntiRevokeTriggers(validIds)
- : { success: true, rows: [] }
- if (!result.success) return result
- return { success: true, rows: [...(result.rows || []), ...invalidRows] }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- async uninstallAntiRevokeTriggers(sessionIds: string[]): Promise<{
- success: boolean
- rows?: Array<{ sessionId: string; success: boolean; error?: string }>
- error?: string
- }> {
- try {
- const connectResult = await this.ensureConnected()
- if (!connectResult.success) return { success: false, error: connectResult.error }
- const { validIds, invalidRows } = await this.filterAntiRevokeSessionIds(sessionIds)
- const result = validIds.length > 0
- ? await wcdbService.uninstallMessageAntiRevokeTriggers(validIds)
- : { success: true, rows: [] }
- if (!result.success) return result
- return { success: true, rows: [...(result.rows || []), ...invalidRows] }
- } catch (e) {
- return { success: false, error: String(e) }
- }
- }
-
- /**
- * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
- */
- async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
- try {
- const connectResult = await this.ensureConnected()
- if (!connectResult.success) {
- return { success: false, error: connectResult.error }
- }
- this.refreshSessionMessageCountCacheScope()
-
- const result = await wcdbService.getSessions()
- if (!result.success || !result.sessions) {
- return { success: false, error: result.error || '获取会话失败' }
- }
- const rows = result.sessions as Record[]
- if (rows.length > 0 && (rows[0]._error || rows[0]._info)) {
- const info = rows[0]
- const detail = info._error || info._info
- const tableInfo = info.table ? ` table=${info.table}` : ''
- const tables = info.tables ? ` tables=${info.tables}` : ''
- const columns = info.columns ? ` columns=${info.columns}` : ''
- return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` }
- }
-
- const openimLocalTypeMap = await this.loadContactLocalTypeMapForEnterpriseOpenim(rows.map((row) =>
- String(
- row.username ||
- row.user_name ||
- row.userName ||
- row.usrName ||
- row.UsrName ||
- row.talker ||
- row.talker_id ||
- row.talkerId ||
- ''
- ).trim()
- ))
-
- // 转换为 ChatSession(先加载缓存,但不等待额外状态查询)
- const sessions: ChatSession[] = []
- const now = Date.now()
- const myWxid = this.configService.getMyWxidCleaned()
-
- for (const row of rows) {
- const username =
- row.username ||
- row.user_name ||
- row.userName ||
- row.usrName ||
- row.UsrName ||
- row.talker ||
- row.talker_id ||
- row.talkerId ||
- ''
-
- let sessionLocalType = this.getSessionLocalType(row)
- if (!Number.isFinite(sessionLocalType) && this.isEnterpriseOpenimUsername(username)) {
- sessionLocalType = openimLocalTypeMap.get(username)
- }
- if (!this.shouldKeepSession(username, sessionLocalType)) continue
-
- const sortTs = parseInt(
- row.sort_timestamp ||
- row.sortTimestamp ||
- row.sort_time ||
- row.sortTime ||
- '0',
- 10
- )
- const lastTs = parseInt(
- row.last_timestamp ||
- row.lastTimestamp ||
- row.last_msg_time ||
- row.lastMsgTime ||
- String(sortTs),
- 10
- )
-
- const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '')
- const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10)
- const messageCountHintRaw =
- row.message_count ??
- row.messageCount ??
- row.msg_count ??
- row.msgCount ??
- row.total_count ??
- row.totalCount ??
- row.n_msg ??
- row.nMsg ??
- row.message_num ??
- row.messageNum
- const parsedMessageCountHint = Number(messageCountHintRaw)
- const messageCountHint = Number.isFinite(parsedMessageCountHint) && parsedMessageCountHint >= 0
- ? Math.floor(parsedMessageCountHint)
- : undefined
-
- // 先尝试从缓存获取联系人信息(快速路径)
- let displayName = username
- let avatarUrl: string | undefined = undefined
- const cached = this.avatarCache.get(username)
- if (cached) {
- displayName = cached.displayName || username
- avatarUrl = cached.avatarUrl
- }
-
- const nextSession: ChatSession = {
- username,
- type: parseInt(row.type || '0', 10),
- unreadCount: parseInt(row.unread_count || row.unreadCount || row.unreadcount || '0', 10),
- summary: summary || this.getMessageTypeLabel(lastMsgType),
- sortTimestamp: sortTs,
- lastTimestamp: lastTs,
- lastMsgType,
- messageCountHint,
- displayName,
- avatarUrl,
- lastMsgSender: row.last_msg_sender,
- lastSenderDisplayName: row.last_sender_display_name,
- selfWxid: myWxid
- }
-
- const cachedStatus = this.sessionStatusCache.get(username)
- if (cachedStatus && now - cachedStatus.updatedAt <= this.sessionStatusCacheTtlMs) {
- nextSession.isFolded = cachedStatus.isFolded
- nextSession.isMuted = cachedStatus.isMuted
- }
-
- sessions.push(nextSession)
-
- if (typeof messageCountHint === 'number') {
- this.sessionMessageCountHintCache.set(username, messageCountHint)
- this.sessionMessageCountCache.set(username, {
- count: messageCountHint,
- updatedAt: Date.now()
- })
- }
- }
-
- await this.addMissingOfficialSessions(sessions, myWxid)
- await this.applySyntheticUnreadCounts(sessions)
- sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0))
-
- // 不等待联系人信息加载,直接返回基础会话列表
- // 前端可以异步调用 enrichSessionsWithContacts 来补充信息
- return { success: true, sessions }
- } catch (e) {
- console.error('ChatService: 获取会话列表失败:', e)
- return { success: false, error: String(e) }
- }
- }
-
- async getAntiRevokeSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
- try {
- const result = await this.getSessions()
- if (!result.success || !Array.isArray(result.sessions)) {
- return { success: false, error: result.error || '获取会话失败' }
- }
-
- return {
- success: true,
- sessions: result.sessions.filter((session) => !String(session.username || '').startsWith('gh_'))
- }
- } catch (e) {
- console.error('ChatService: 获取防撤回会话列表失败:', e)
- return { success: false, error: String(e) }
- }
- }
-
- async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
- try {
- const connectResult = await this.ensureConnected()
- if (!connectResult.success) {
- return { success: false, error: connectResult.error }
- }
- const result = await wcdbService.markAllSessionsRead()
- if (result.success) {
- this.syntheticUnreadState.clear()
- }
- return result
- } catch (e) {
- console.error('ChatService: 一键已读失败:', e)
- return { success: false, error: String(e) }
- }
- }
-
- private getSessionUsername(row: Record): string {
- return String(
- row.username ||
- row.user_name ||
- row.userName ||
- row.usrName ||
- row.UsrName ||
- row.talker ||
- row.talker_id ||
- row.talkerId ||
- ''
- ).trim()
- }
-
- private isAntiRevokeContactRow(username: string, row: Record): boolean {
- if (!username) return false
- if (username.endsWith('@chatroom')) return true
- if (username.startsWith('gh_')) return false
-
- const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], Number.NaN)
- const lowered = username.toLowerCase()
- if (this.isEnterpriseOpenimUsername(username)) {
- return this.isAllowedEnterpriseOpenimByLocalType(username, localType)
- }
- if (lowered.startsWith('weixin') && lowered !== 'weixin') return true
- return localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)
- }
-
- private async loadAntiRevokeContactMap(usernames: string[]): Promise