feat(desktop): open-source the Electron desktop app (build, electron main/preload, renderer, config)

This commit is contained in:
matevip 2026-06-25 16:00:39 +08:00
parent e4e7b4c377
commit b563f93c1d
26 changed files with 7407 additions and 0 deletions

View File

@ -0,0 +1,15 @@
# macOS 代码签名与公证
# 本地构建推荐不设 CSC_LINK让 electron-builder 自动从钥匙串发现证书
# CSC_LINK=/path/to/developer_id_application.p12 # CI/CD 专用
# CSC_KEY_PASSWORD= # CI/CD 专用
APPLE_ID=your@apple.id
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
APPLE_TEAM_ID=XXXXXXXXXX
# GitHub Releases 发布
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Windows 代码签名(可选)
# WIN_CSC_LINK=/path/to/windows-cert.pfx
# WIN_CSC_KEY_PASSWORD=

33
mateclaw-desktop/.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build output
dist/
dist-electron/
release/
# Resources (downloaded/generated, not committed)
resources/jre/
resources/app.jar
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Runtime data (H2 database created during dev testing)
data/
# Environment
.env
.env.local

View File

@ -0,0 +1,275 @@
# macOS 代码签名证书操作指南
本文档详细说明如何创建、导出和配置 macOS **Developer ID Application** 证书,用于 MateClaw Desktop 的签名与公证。
---
## 前置条件
- [Apple Developer Program](https://developer.apple.com/programs/) 会员($99/年)
- macOS 系统(需要钥匙串访问生成密钥对)
## Step 1: 撤销旧证书(如有)
如果本地证书已过期或私钥丢失,需先撤销线上旧证书:
1. 登录 https://developer.apple.com/account/resources/certificates/list
2. 找到旧的 `Developer ID Application` 证书 → 点击进入详情
3. 点击 **Revoke** → 确认撤销
4. 回到本地 **钥匙串访问** → 删除过期证书(右键 → 删除)
## Step 2: 生成 CSR证书签名请求
CSR 会在本地生成密钥对(私钥留在钥匙串,公钥随 CSR 提交给 Apple
1. 打开 **钥匙串访问**
2. 菜单栏 → 钥匙串访问 → **证书助理** → **从证书颁发机构请求证书…**
3. 填写:
- **用户电子邮件地址**:你的 Apple ID 邮箱
- **常用名称**:与开发者账号一致(如 `ZHANFU XU`
- **CA 电子邮件地址**:留空
- **请求是**:选择 **存储到磁盘**
4. 保存 `CertificateSigningRequest.certSigningRequest` 到桌面
## Step 3: 创建 Developer ID Application 证书
1. 访问 https://developer.apple.com/account/resources/certificates/add
2. 在 **Software** 分类下,选择 **Developer ID Application**
3. 点击 **Continue**
4. 上传 Step 2 保存的 CSR 文件
5. 点击 **Continue****Download** 下载 `developerID_application.cer`
6. **双击**下载的 `.cer` 文件 → 自动安装到钥匙串
## Step 4: 验证安装
```bash
security find-identity -v -p codesigning | grep "Developer ID Application"
```
应输出类似:
```
"Developer ID Application: ZHANFU XU (MR97WAD978)"
```
在钥匙串访问 → 登录 → **我的证书**中,展开该证书应能看到关联的**私钥**(左侧三角展开)。
## Step 5: 导出 .p12 文件
`.p12` 文件包含证书 + 私钥,是 `electron-builder` 签名所需的文件。
1. 钥匙串访问 → 登录 → **我的证书**
2. 找到 `Developer ID Application: Your Name (TEAMID)`
3. 点左侧三角**展开**,确认包含私钥
4. **右键证书**(不是私钥)→ **导出…**
5. 格式选择:**个人信息交换 (.p12)**
6. 保存为 `developer_id_application.p12`
7. 设置一个强密码(后续用作 `CSC_KEY_PASSWORD` 环境变量)
> **安全提醒**`.p12` 文件包含私钥,绝不要提交到 Git 仓库。
## Step 6: 创建 App 专用密码(公证用)
Apple 公证notarization需要通过 Apple ID 验证身份,使用 App 专用密码代替账号密码。
1. 访问 https://appleid.apple.com/account/manage
2. 登录 → **登录与安全****App 专用密码** → **生成**
3. 标签填:`mateclaw-notarize`
4. 记录生成的密码(格式如 `xxxx-xxxx-xxxx-xxxx`
## Step 7: 查找 Team ID
```bash
security find-identity -v -p codesigning | grep "Developer ID Application"
```
输出中括号内的 10 位字母数字即为 Team ID`MR97WAD978`)。
## Step 8: 配置环境变量并构建
### 方式 A本地钥匙串自动发现推荐
证书已安装到本地钥匙串时,**不需要设置 `CSC_LINK``CSC_KEY_PASSWORD`**electron-builder 会自动从钥匙串中发现 Developer ID Application 证书。
```bash
cd mateclaw-desktop
# 只需设置公证相关变量
export APPLE_ID="your@apple.id"
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="XXXXXXXXXX"
# 执行签名+公证构建
bash scripts/build-all-platforms.sh --mac-only
```
> **为什么推荐这种方式?** 设置 `CSC_LINK`electron-builder 会创建一个临时钥匙串来导入 `.p12` 文件,这可能导致签名过程静默卡死(无报错)。直接使用本地钥匙串可以避免此问题。
### 方式 B指定 .p12 文件CI/CD 专用)
在 CI/CD 环境或证书不在本地钥匙串时,需要通过环境变量指定 `.p12` 文件:
```bash
cd mateclaw-desktop
export CSC_LINK="$HOME/developer_id_application.p12"
export CSC_KEY_PASSWORD="你的p12密码"
export APPLE_ID="your@apple.id"
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="XXXXXXXXXX"
bash scripts/build-all-platforms.sh --mac-only
```
> **注意**`CSC_KEY_PASSWORD` 中如有特殊字符(`$`、`!`、`"`、`` ` ``),必须用**单引号**包裹,如 `export CSC_KEY_PASSWORD='pa$$w0rd!'`。
### GitHub Actions Secrets
`.p12` 文件 Base64 编码后存为 GitHub Secret
```bash
base64 -i developer_id_application.p12 | pbcopy
# 粘贴到 GitHub Secret: MAC_CSC_LINK
```
| GitHub Secret | 值 |
|---|---|
| `MAC_CSC_LINK` | `.p12` 的 Base64 内容 |
| `MAC_CSC_KEY_PASSWORD` | `.p12` 密码 |
| `APPLE_ID` | Apple ID 邮箱 |
| `APPLE_APP_SPECIFIC_PASSWORD` | App 专用密码 |
| `APPLE_TEAM_ID` | 10 位 Team ID |
## Step 9: 验证签名和公证
构建完成后验证:
```bash
# 验证代码签名
codesign --verify --deep --strict release/mac-arm64/MateClaw.app
# 验证 Gatekeeper 公证状态
spctl --assess --type execute --verbose release/mac-arm64/MateClaw.app
# 期望输出: accepted, source=Developer ID
# 验证 DMG
spctl --assess --type open --context context:primary-signature release/MateClaw-*.dmg
```
---
## 故障排查
### 签名卡死(无报错)
**现象**:构建停在 `signing` 行不动,`ps aux | grep codesign` 无进程或进程短暂出现后消失。
**原因**:设置了 `CSC_LINK`electron-builder 会创建临时钥匙串导入 `.p12`,临时钥匙串的访问权限可能导致 `codesign` 静默卡死。
**解决**
```bash
# 方案一(推荐):取消 CSC_LINK使用本地钥匙串自动发现
unset CSC_LINK
unset CSC_KEY_PASSWORD
# 方案二:授权 codesign 访问钥匙串
security unlock-keychain -p "你的Mac登录密码" ~/Library/Keychains/login.keychain-db
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "你的Mac登录密码" ~/Library/Keychains/login.keychain-db
```
### `Permission denied` (classes.jsa)
**现象**`codesign` 报错 `Permission denied`,通常指向 JRE 中的 `classes.jsa` 文件。
**原因**:下载的 Adoptium JRE 中部分文件是只读的,`codesign --force` 需要写权限。
**解决**`download-jre.sh` 已在解压后自动执行 `chmod -R u+w`。如果使用旧版 JRE手动修复
```bash
# 删除旧 JRE 重新下载(推荐)
rm -rf resources/jre/mac-arm64 resources/jre/mac-x64
npm run setup:jre
# 或手动修复权限
chmod -R u+w resources/jre/
```
### `MAC verification failed` (wrong password)
**现象**`SecKeychainItemImport: MAC verification failed during PKCS12 import (wrong password?)`
**原因**`CSC_KEY_PASSWORD` 与导出 `.p12` 时设置的密码不匹配。
**解决**
```bash
# 验证密码是否正确
openssl pkcs12 -in ~/developer_id_application.p12 -nokeys -passin pass:"你的密码"
# 如果报错 mac verify failure重新导出 .p12
# 钥匙串访问 → 我的证书 → 右键 Developer ID Application → 导出 → 重新设置密码
# 注意特殊字符需用单引号包裹
export CSC_KEY_PASSWORD='pa$$w0rd!'
```
### 公证上传超时 (deadlineExceeded)
**现象**`HTTPClientError.deadlineExceeded`,公证上传到 Apple S3 超时。
**原因**:网络到 Apple 服务器不稳定700MB+ 的应用上传容易超时。
**解决**:先跳过公证构建,再用 `xcrun notarytool` 手动公证(支持断点续传,超时容忍度更高):
```bash
# 1. 去掉公证变量,仅签名
unset APPLE_ID
unset APPLE_APP_SPECIFIC_PASSWORD
unset APPLE_TEAM_ID
bash scripts/build-all-platforms.sh --mac-only
# 2. 手动公证
xcrun notarytool submit release/MateClaw_1.0.0_arm64.zip \
--apple-id "your@apple.id" \
--password "app专用密码" \
--team-id "XXXXXXXXXX" \
--wait
xcrun notarytool submit release/MateClaw_1.0.0_x64.zip \
--apple-id "your@apple.id" \
--password "app专用密码" \
--team-id "XXXXXXXXXX" \
--wait
# 3. 装订公证票据到 DMG
xcrun stapler staple release/MateClaw_1.0.0_arm64.dmg
xcrun stapler staple release/MateClaw_1.0.0_x64.dmg
```
---
## 常见问题
### 证书过期了怎么办?
Developer ID Application 证书有效期 **5 年**。过期后需重复 Step 1 ~ Step 5 重新创建。Apple 会在自动轮换日期前通过邮件提醒。
### 导出 .p12 时没有"导出"选项?
说明本地钥匙串中没有该证书对应的私钥。私钥只存在于当初生成 CSR 的那台 Mac 上。解决方案:
- **方案 A**:在原 Mac 上导出 `.p12`,再导入到当前 Mac
- **方案 B**:撤销旧证书,在当前 Mac 重新创建Step 1 ~ Step 5
### 签名很慢正常吗?
正常。700MB+ 的应用(含 JRE + Electron Framework签名需要 **15~30 分钟**,公证上传+审核需要额外 **5~15 分钟**。可以用以下命令监控签名进度:
```bash
watch -n 2 'ps aux | grep codesign | grep -v grep'
# macOS 需先安装brew install watch
```
### 跳过签名(开发测试用)
```bash
export CSC_IDENTITY_AUTO_DISCOVERY=false
bash scripts/build-all-platforms.sh --mac-only
```
未签名的应用无法使用自动升级功能macOS 用户需手动下载 DMG 安装。

432
mateclaw-desktop/README.md Normal file
View File

@ -0,0 +1,432 @@
# MateClaw Desktop
MateClaw 的桌面客户端,基于 Electron 构建,自动集成 JRE 21 和后端服务,实现双击即用。
## 架构
```
Electron Shell
├── Splash Screen (Vue 3) ← 启动加载界面
├── Bundled JRE 21 ← 自带 Java 运行时
├── mateclaw-server.jar ← Spring Boot 后端 + Vue 前端
└── BrowserWindow → localhost:18088
```
**启动流程**: Electron 启动 → 显示 Splash → 用内置 JRE 启动 JAR → 等待后端就绪 → 加载主界面
## 快速开始
### 前置要求
- Node.js 18+
- pnpm (前端构建)
- Maven 3.9+ (后端构建)
- Java 21+ (仅构建时需要,运行时使用内置 JRE)
### 开发模式
```bash
# 1. 安装依赖
npm install
# 2. 构建后端 JAR包含前端资源
npm run setup:jar
# 3. 下载 JRE当前平台
npm run setup:jre
# 4. 启动开发模式
npm run dev
```
### 打包发布
```bash
# macOS (.dmg)
npm run package:mac
# Windows (.exe)
npm run package:win
# 全平台
npm run package:all
```
输出在 `release/` 目录。
## 目录结构
```
mateclaw-desktop/
├── electron/main/ # Electron 主进程Java 生命周期管理)
├── electron/preload/ # 预加载脚本(安全 IPC 桥接)
├── src/ # Splash ScreenVue 3 加载页面)
├── build/ # 应用图标和 macOS entitlements
├── scripts/ # 构建脚本
│ ├── download-jre.sh # 下载 Adoptium JRE 21
│ └── build.sh # 构建前端 + 后端 JAR
└── resources/ # 运行时资源JRE + JAR不提交到 Git
```
## 环境变量
桌面应用**不需要任何环境变量**就能启动——LLM 供应商 Key 在 UI 里加。
以下是可选的环境变量(桌面应用会继承系统环境):
| 变量 | 必须 | 说明 |
|------|------|------|
| `SERPER_API_KEY` | ❌ | Google Serper 搜索 API搜索工具暂未迁到 UI |
| `TAVILY_API_KEY` | ❌ | Tavily 搜索 API |
> 💡 DashScope / OpenAI / Anthropic / DeepSeek / Kimi / Ollama 等 LLM 供应商 Key 启动后在「设置 → 模型 → 添加供应商」里粘进去,加密存到本地 H2 数据库。
## 自动升级
应用内置 `electron-updater` 自动升级,更新产物托管在 [GitHub Releases](https://github.com/matevip/mateclaw/releases)。
**升级流程**:启动时检查 → Splash Screen 底部通知 → 用户点击下载 → 下载完成点击重启 → 自动停止 Java 后端 → 安装新版本
| 平台 | 更新包格式 | 元数据文件 | 签名要求 |
|------|-----------|-----------|---------|
| Windows | NSIS `.exe` | `latest.yml` | 可选(不签名会触发 SmartScreen |
| macOS | `.zip` | `latest-mac.yml` | **必须签名+公证**(否则只能手动 DMG 安装) |
## 发布操作手册
### 第一步:配置 GitHub Token
`electron-builder` 使用 `github` provider需要 GitHub Personal Access Token 来创建 Release 并上传产物。
1. 前往 https://github.com/settings/tokens → **Generate new token (classic)**
2. 勾选 `repo` 权限(需要完整 repo 访问才能创建 Release
3. 生成后保存 token
```bash
# 设置环境变量(建议写入 ~/.zshrc 或 CI Secret
export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### 第二步:版本号管理
每次发布前必须更新 `package.json` 中的 `version` 字段。`electron-updater` 客户端通过对比本地版本号和 `latest.yml` 中的版本号来判断是否有更新。
```bash
# 编辑版本号
cd mateclaw-desktop
vim package.json # 修改 "version": "1.0.0" → "1.1.0"
```
版本号遵循 [SemVer](https://semver.org/)
- 修复 bug → `1.0.0``1.0.1`
- 新功能 → `1.0.0``1.1.0`
- 破坏性变更 → `1.0.0``2.0.0`
### 第三步:构建并发布
```bash
cd mateclaw-desktop
# 一键构建全平台 + 自动上传到 GitHub Releases
export GH_TOKEN=ghp_xxxxxxxxxxxx
bash scripts/build-all-platforms.sh --all --publish=always
```
这会自动:
1. 构建后端 JAR
2. 下载各平台 JRE
3. 编译前端
4. 打包 macOSDMG + ZIP和 WindowsNSIS
5. 生成 `latest.yml``latest-mac.yml`
6. 创建 GitHub Draft Release 并上传所有产物
完成后前往 https://github.com/matevip/mateclaw/releases ,找到 Draft Release
- 填写 Release Notes更新说明
- 点击 **Publish release** 正式发布
也可以仅构建特定平台:
```bash
bash scripts/build-all-platforms.sh --mac-only --publish=always # 仅 macOS
bash scripts/build-all-platforms.sh --win-only --publish=always # 仅 Windows
```
### 第四步(可选):手动发布
如果不想用 `--publish=always` 自动上传:
```bash
# 1. 仅构建,不上传
bash scripts/build-all-platforms.sh --all
# 2. 查看生成的产物
ls -la release/
# 产物包括:
# MateClaw_1.1.0_arm64.dmg macOS ARM64 安装包
# MateClaw_1.1.0_x64.dmg macOS x64 安装包
# MateClaw_1.1.0_arm64.zip macOS ARM64 更新包(升级用)
# MateClaw_1.1.0_x64.zip macOS x64 更新包(升级用)
# MateClaw_1.1.0_x64_Setup.exe Windows x64 安装包
# MateClaw_1.1.0_arm64_Setup.exe Windows ARM64 安装包
# MateClaw_1.1.0_*.blockmap 差分下载支持文件
# latest.yml Windows 更新元数据
# latest-mac.yml macOS 更新元数据
# 3. 在 GitHub 手动创建 Release
# Tag: v1.1.0
# 上传 release/ 目录中的所有 .exe .zip .dmg .blockmap .yml 文件
```
> **注意**`latest.yml` 和 `latest-mac.yml` 必须上传,客户端靠它们检测新版本。
---
## macOS 代码签名与公证
macOS 自动升级**必须**签名+公证,否则 Gatekeeper 会阻止更新后的应用启动。未签名时 macOS 用户只能手动下载 DMG 安装。
> **证书创建完整指南**:首次配置或证书过期时,参见 [CODESIGNING.md](./CODESIGNING.md)(含 CSR 生成、证书创建、.p12 导出、公证配置等完整步骤)。
### 本地签名构建(推荐)
证书安装到本地钥匙串后,**不需要设置 `CSC_LINK`**electron-builder 会自动发现证书:
```bash
# 只需设置公证相关变量
export APPLE_ID=your@apple.id
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx # 在 appleid.apple.com 生成
export APPLE_TEAM_ID=XXXXXXXXXX # 10 位团队 ID
bash scripts/build-all-platforms.sh --mac-only --publish=always
```
`electron-builder` 会自动完成签名 → 公证 → 装订staple→ 上传。
> **注意**:不要设置 `CSC_LINK` 环境变量,否则 electron-builder 会创建临时钥匙串,可能导致签名卡死。详见 [CODESIGNING.md](./CODESIGNING.md) 故障排查章节。
### CI/CD 签名构建
CI 环境无本地钥匙串,需通过 `CSC_LINK` 指定 `.p12` 文件Base64 编码存入 GitHub Secret
```bash
export CSC_LINK=base64_encoded_p12_content
export CSC_KEY_PASSWORD=your_certificate_password
export APPLE_ID=your@apple.id
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
export APPLE_TEAM_ID=XXXXXXXXXX
bash scripts/build-all-platforms.sh --mac-only --publish=always
```
### 公证超时处理
如果公证上传超时(`deadlineExceeded`),可先跳过公证构建,再用 `xcrun notarytool` 手动公证:
```bash
# 1. 去掉公证变量,仅签名出包
unset APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID
bash scripts/build-all-platforms.sh --mac-only
# 2. 手动公证(支持断点续传)
xcrun notarytool submit release/MateClaw_*.zip \
--apple-id your@apple.id \
--password "app专用密码" \
--team-id XXXXXXXXXX \
--wait
# 3. 装订公证票据
xcrun stapler staple release/MateClaw_*.dmg
```
### 跳过签名(开发/测试用)
```bash
export CSC_IDENTITY_AUTO_DISCOVERY=false
bash scripts/build-all-platforms.sh --mac-only
```
---
## Windows 代码签名(可选)
未签名的 Windows 安装包会触发 SmartScreen 警告("Windows 已保护你的电脑"),用户可以点击"仍要运行"。签名可消除此警告。
### EV 代码签名证书
推荐使用 EVExtended Validation证书可立即获得 SmartScreen 信誉,无需积累安装量。
证书提供商(参考):
- [DigiCert](https://www.digicert.com/signing/code-signing-certificates) — 需硬件 token
- [SSL.com](https://www.ssl.com/certificates/ev-code-signing/) — 支持云签名
- [Certum](https://shop.certum.eu/code-signing-certificates/) — 较便宜的选项
### 配置
```bash
# PFX 文件签名
export WIN_CSC_LINK=/path/to/windows-cert.pfx
export WIN_CSC_KEY_PASSWORD=password
# 或使用 signtool需要硬件 token 的 EV 证书)
# 在 electron-builder.json 的 win 节中配置:
# "signingHashAlgorithms": ["sha256"],
# "sign": "./scripts/sign.js"
```
---
## CI/CD 自动发布GitHub Actions
以下为 GitHub Actions 完整示例,实现 Git tag 推送时自动构建全平台并发布:
```yaml
# .github/workflows/release.yml
name: Release Desktop
on:
push:
tags:
- 'v*' # 推送 v1.0.0 等 tag 时触发
jobs:
release-mac:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Build and publish macOS
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
cd mateclaw-desktop
npm install
bash scripts/build-all-platforms.sh --mac-only --publish=always
release-win:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Build and publish Windows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd mateclaw-desktop
npm install
bash scripts/build-all-platforms.sh --win-only --publish=always
```
### 配置 CI Secrets
在 GitHub 仓库 → Settings → Secrets and variables → Actions → New repository secret
| Secret 名称 | 说明 |
|-------------|------|
| `MAC_CSC_LINK` | macOS 签名证书 .p12 的 Base64 编码:`base64 -i cert.p12 \| tr -d '\n'` |
| `MAC_CSC_KEY_PASSWORD` | .p12 证书密码 |
| `APPLE_ID` | Apple ID 邮箱 |
| `APPLE_APP_SPECIFIC_PASSWORD` | App 专用密码 |
| `APPLE_TEAM_ID` | 10 位开发者团队 ID |
| `GITHUB_TOKEN` | 自动提供,无需手动配置 |
### 发布流程CI 方式)
```bash
# 1. 更新版本号
cd mateclaw-desktop
vim package.json # "version": "1.1.0"
# 2. 提交并打 tag
git add -A && git commit -m "release: v1.1.0"
git tag v1.1.0
git push origin main --tags
# 3. GitHub Actions 自动构建并创建 Draft Release
# 4. 前往 GitHub Releases 确认并发布
```
---
## 本地测试自动升级
### 方式一:开发模式 + dev-app-update.yml
在开发模式下测试 updater 流程(不需要打包):
```bash
# 1. 在 mateclaw-desktop/ 根目录创建 dev-app-update.yml
cat > dev-app-update.yml << 'EOF'
provider: generic
url: http://localhost:8080/
EOF
# 2. 构建一个"新版本"的产物
# 先把 package.json 的 version 改为更高版本(如 9.9.9
# 然后构建:
npm run build
npx electron-builder --mac --publish=never # 或 --win
# 构建完成后把 version 改回原值
# 3. 启动本地文件服务器
cd release && python3 -m http.server 8080
# 4. 另一个终端启动开发模式
cd mateclaw-desktop && npm run dev
# updater 会从 localhost:8080 检查更新并发现"新版本"
```
> 开发模式下 `quitAndInstall()` 不会真正安装,但可验证检查→发现→下载的完整流程。
### 方式二:打包后端到端测试(推荐)
```bash
# 1. 打包 v1.0.0 并安装到系统
# 2. 修改 package.json version 为 v1.1.0
# 3. 重新构建,产物上传到 GitHub Release或本地服务器
# 4. 启动已安装的 v1.0.0,观察完整升级流程:
# 检查更新 → 发现 v1.1.0 → 下载 → 重启安装
```
---
## 发布检查单
- [ ] `package.json` 版本号已更新
- [ ] 后端 JAR 已构建(`npm run setup:jar`
- [ ] 各平台 JRE 已下载
- [ ] `npm run build` 编译通过
- [ ] `GH_TOKEN` 环境变量已设置
- [ ] macOS 签名证书环境变量已设置(若需要签名)
- [ ] `bash scripts/build-all-platforms.sh --all --publish=always` 执行成功
- [ ] GitHub Draft Release 已确认发布
- [ ] 在旧版本应用上验证升级通知正常
## 技术栈
- **Electron** - 桌面应用框架
- **Vite + Vue 3** - Splash Screen 构建
- **electron-builder** + **electron-updater** - 跨平台打包与自动升级
- **Adoptium JRE 21** - 内置 Java 运行时

View File

@ -0,0 +1,67 @@
# MateClaw v1.0.101
## What's New
### Mobile Responsive UI
- Sidebar transforms to slide-in drawer with hamburger menu on mobile (<=768px)
- Conversation panel becomes a toggleable overlay on mobile
- Welcome screen centers properly with auto text wrapping, single-column suggestion cards
- Chat header auto-simplifies: icon-only agent badge, adaptive model selector
- Reduced padding/gaps across all chat components for mobile screens
### Drag & Drop File Upload
- Drag-and-drop files and folders directly into the chat area
- Electron: directory references via local path; Web: recursive file collection and upload
### Multi-Agent Collaboration
- `DelegateAgentTool` for agent-to-agent task delegation
### LLM Context Awareness
- Current datetime automatically injected into LLM context for time-aware responses
### MCP Server
- Pre-configured GitHub MCP Server in seed data (ready to use out of the box)
### Ollama Auto-Discovery
- Auto-detect local Ollama instance on startup
- Pre-configured 6 popular local models (Qwen3, Llama, DeepSeek, Gemma, Phi, Mistral)
- Local providers sorted first in model management UI
### Model Management Enhancements
- Provider list grouped by Local / Cloud with section headers
- Zhipu AI models updated to GLM-5 series (GLM-5-Turbo / GLM-5V-Turbo / GLM-5 / GLM-5.1)
- 20+ model providers supported
### API Docs
- Replaced Knife4j with SpringDoc OpenAPI 2.8.16 (`/swagger-ui.html`)
## Bug Fixes
- **Security**: Fixed SPA frontend route refresh returning 401
- **i18n**: Window title dynamically set from language pack instead of hardcoded
- **i18n**: Fixed 5 hardcoded Chinese strings in approval bar
- **i18n**: Fixed hardcoded time formatting (locale-aware now)
- **Guard**: Aligned tool guard rule names with runtime `@Tool` method names
- **LLM**: Fixed Zhipu connection test 404
- **UI**: Fixed suggestion cards grid misalignment with longer text
- **Upload**: File upload size limit raised to 100MB
## Download
| Platform | File | Note |
|----------|------|------|
| macOS Apple Silicon | `MateClaw_1.0.101_arm64.dmg` | M1 / M2 / M3 / M4 / M5 |
| macOS Intel | `MateClaw_1.0.101_x64.dmg` | Intel Mac |
| Windows | `MateClaw_1.0.101_Setup.exe` | Windows 10/11 (x64+arm64) |
| Windows x64 | `MateClaw_1.0.101_x64_Setup.exe` | Windows 10/11 x64 |
| Windows ARM64 | `MateClaw_1.0.101_arm64_Setup.exe` | Windows ARM64 |
> zip / blockmap / yml files are for auto-update support.
## Links
- GitHub: https://github.com/matevip/mateclaw
- Gitee: https://gitee.com/matevip_admin/mateclaw
- Documentation: https://mateclaw.com
**Full Changelog**: https://github.com/matevip/mateclaw/compare/v1.0.0...v1.0.101

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -0,0 +1,96 @@
{
"appId": "vip.mate.mateclaw",
"productName": "MateClaw",
"copyright": "Copyright © 2026 MateClaw Team",
"directories": {
"output": "release"
},
"publish": [
{
"provider": "github",
"owner": "matevip",
"repo": "mateclaw"
}
],
"files": [
"dist-electron",
"dist"
],
"afterPack": "scripts/trim-playwright-driver.cjs",
"extraResources": [
{
"from": "resources/jre/${os}-${arch}/",
"to": "jre/",
"filter": ["**/*"]
},
{
"from": "resources/app.jar",
"to": "app.jar"
}
],
"mac": {
"category": "public.app-category.productivity",
"target": [
{
"target": "dmg",
"arch": ["arm64", "x64"]
},
{
"target": "zip",
"arch": ["arm64", "x64"]
}
],
"icon": "build/icon.icns",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.inherit.plist",
"artifactName": "MateClaw_${version}_${arch}.${ext}"
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
],
"title": "MateClaw ${version}"
},
"win": {
"target": [
{
"target": "nsis",
"arch": "x64"
},
{
"target": "nsis",
"arch": "arm64"
}
],
"icon": "build/icon.ico",
"artifactName": "MateClaw_${version}_${arch}_Setup.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"installerHeaderIcon": "build/icon.ico",
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": ["AppImage"],
"icon": "build/icon.png",
"category": "Utility",
"artifactName": "MateClaw_${version}.${ext}"
}
}

View File

@ -0,0 +1,87 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readFileSync, writeFileSync } from 'fs'
// ─── Connection configuration ────────────────────────────────────────────────
// Persists how the desktop shell reaches its backend: either an embedded local
// JVM ("local") or a centrally deployed remote server ("remote"). Stored as a
// small JSON file in userData so no extra dependency is required.
export type ConnectionMode = 'local' | 'remote'
export interface RemoteServer {
url: string
name?: string
lastUsed?: number
}
export interface ConnectionConfig {
// null = no choice made yet (first run → show the connection chooser)
mode: ConnectionMode | null
remoteUrl: string
servers: RemoteServer[]
}
const DEFAULT_CONFIG: ConnectionConfig = {
mode: null,
remoteUrl: '',
servers: [],
}
function getConfigPath(): string {
return join(app.getPath('userData'), 'connection.json')
}
export function loadConfig(): ConnectionConfig {
try {
const path = getConfigPath()
if (!existsSync(path)) return { ...DEFAULT_CONFIG }
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<ConnectionConfig>
return {
...DEFAULT_CONFIG,
...raw,
servers: Array.isArray(raw.servers) ? raw.servers : [],
}
} catch (err) {
console.error('[MateClaw] Failed to read connection config:', err)
return { ...DEFAULT_CONFIG }
}
}
export function saveConfig(patch: Partial<ConnectionConfig>): ConnectionConfig {
const merged: ConnectionConfig = { ...loadConfig(), ...patch }
try {
writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2), 'utf-8')
} catch (err) {
console.error('[MateClaw] Failed to write connection config:', err)
}
return merged
}
// Normalize a user-entered server URL: trim, default to https when no scheme is
// given, and strip a trailing slash. Returns null when the input cannot form a
// valid http(s) URL.
export function normalizeServerUrl(input: string): string | null {
const trimmed = (input || '').trim()
if (!trimmed) return null
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
try {
const url = new URL(withScheme)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
// Drop a trailing slash on the path-less root so URLs compare cleanly.
return withScheme.replace(/\/+$/, '')
} catch {
return null
}
}
// Record a successful remote connection in the most-recently-used server list,
// de-duplicating by URL and capping the history length.
export function recordServer(url: string, name?: string): ConnectionConfig {
const cfg = loadConfig()
const now = Date.now()
const without = cfg.servers.filter((s) => s.url !== url)
const servers: RemoteServer[] = [{ url, name, lastUsed: now }, ...without].slice(0, 8)
return saveConfig({ servers })
}

View File

@ -0,0 +1,920 @@
import { app, BrowserWindow, shell, ipcMain, dialog, Menu, nativeImage } from 'electron'
import { join, resolve } from 'path'
import { ChildProcess, spawn } from 'child_process'
import { existsSync, mkdirSync } from 'fs'
import http from 'http'
import https from 'https'
import net from 'net'
import { autoUpdater } from 'electron-updater'
import type { UpdateInfo, ProgressInfo } from 'electron-updater'
import {
loadConfig,
saveConfig,
normalizeServerUrl,
recordServer,
type ConnectionMode,
} from './config'
// ─── Constants ───────────────────────────────────────────────────────────────
let BACKEND_PORT = 0
let BACKEND_URL = ''
const HEALTH_CHECK_INTERVAL = 1000 // ms
const HEALTH_CHECK_TIMEOUT = 120_000 // 2 minutes max wait
const WINDOW_WIDTH = 1280
const WINDOW_HEIGHT = 860
// ─── State ───────────────────────────────────────────────────────────────────
let mainWindow: BrowserWindow | null = null
let javaProcess: ChildProcess | null = null
let isQuitting = false
let isUpdating = false
let backendReady = false
// Connection state: which backend the shell is talking to.
let connectionMode: ConnectionMode | null = null
// When true, the splash shows the connection chooser even if a mode was saved
// (used by the "Switch Server" menu action).
let forceChooser = false
// Hosts whose TLS certificate the user explicitly trusted this session (covers
// enterprise self-signed certificates on remote servers).
const trustedCertHosts = new Set<string>()
// Reachability probes (health poll / test button) must not hard-fail on an
// untrusted certificate — that only signals reachability. The real trust
// decision still happens at BrowserWindow navigation via the certificate-error
// handler, which prompts the user before loading the page.
const insecureAgent = new https.Agent({ rejectUnauthorized: false })
interface UpdaterState {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
version?: string
releaseNotes?: string
progress?: { percent: number; bytesPerSecond: number; transferred: number; total: number }
error?: string
}
let updaterState: UpdaterState = { status: 'idle' }
// ─── Platform Detection & Resource Paths ─────────────────────────────────────
function getResourcesPath(): string {
// In production: process.resourcesPath points to <app>/Contents/Resources (macOS) or <app>/resources (Windows)
// In dev: use the local resources/ directory
if (app.isPackaged) {
return process.resourcesPath
}
return resolve(__dirname, '../../resources')
}
function getJavaExecutable(): string {
const resourcesPath = getResourcesPath()
const platform = process.platform
// In production: extraResources copies jre/<platform>/* → Resources/jre/
// In dev: jre is at resources/jre/<platform-arch>/
const jrePath = join(resourcesPath, 'jre')
// Candidate paths for java binary (try all known layouts)
const candidates: string[] = []
if (platform === 'darwin') {
// Packaged: jre/Contents/Home/bin/java
candidates.push(join(jrePath, 'Contents', 'Home', 'bin', 'java'))
// Dev: jre/mac-arm64/Contents/Home/bin/java
candidates.push(join(jrePath, 'mac-arm64', 'Contents', 'Home', 'bin', 'java'))
candidates.push(join(jrePath, 'mac-x64', 'Contents', 'Home', 'bin', 'java'))
// Fallback: flat layout
candidates.push(join(jrePath, 'bin', 'java'))
} else if (platform === 'win32') {
candidates.push(join(jrePath, 'bin', 'java.exe'))
candidates.push(join(jrePath, 'win32-x64', 'bin', 'java.exe'))
} else {
candidates.push(join(jrePath, 'bin', 'java'))
candidates.push(join(jrePath, 'linux-x64', 'bin', 'java'))
candidates.push(join(jrePath, 'linux-arm64', 'bin', 'java'))
}
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
// Return first candidate for error reporting
return candidates[0]
}
function getJarPath(): string {
const resourcesPath = getResourcesPath()
return join(resourcesPath, 'app.jar')
}
function getUserDataPath(): string {
const dataPath = join(app.getPath('userData'), 'data')
if (!existsSync(dataPath)) {
mkdirSync(dataPath, { recursive: true })
}
return app.getPath('userData')
}
// ─── Java Backend Lifecycle ──────────────────────────────────────────────────
function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as net.AddressInfo
server.close(() => resolve(port))
})
server.on('error', reject)
})
}
async function startJavaBackend(): Promise<void> {
BACKEND_PORT = await getAvailablePort()
BACKEND_URL = `http://localhost:${BACKEND_PORT}`
console.log(`[MateClaw] Using dynamic port: ${BACKEND_PORT}`)
const javaExec = getJavaExecutable()
const jarPath = getJarPath()
const workingDir = getUserDataPath()
console.log(`[MateClaw] Java executable: ${javaExec}`)
console.log(`[MateClaw] JAR path: ${jarPath}`)
console.log(`[MateClaw] Working directory: ${workingDir}`)
if (!existsSync(javaExec)) {
console.error(`[MateClaw] Java executable not found: ${javaExec}`)
dialog.showErrorBox(
'MateClaw 启动失败',
`找不到 Java 运行时环境。\n路径: ${javaExec}\n\n请重新安装 MateClaw。`
)
app.quit()
return
}
if (!existsSync(jarPath)) {
console.error(`[MateClaw] JAR not found: ${jarPath}`)
dialog.showErrorBox(
'MateClaw 启动失败',
`找不到应用程序包。\n路径: ${jarPath}\n\n请重新安装 MateClaw。`
)
app.quit()
return
}
// Prepare environment variables — inherit current env + override
const env = {
...process.env,
// Ensure H2 database is stored in userData
SPRING_DATASOURCE_URL: `jdbc:h2:file:${join(workingDir, 'data', 'mateclaw')};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE`,
}
// Spawn Java process
javaProcess = spawn(javaExec, [
'-jar',
jarPath,
`--server.port=${BACKEND_PORT}`,
'--mateclaw.setup.await-language-selection=true',
], {
cwd: workingDir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
javaProcess.stdout?.on('data', (data: Buffer) => {
const line = data.toString().trim()
if (line) console.log(`[Java] ${line}`)
})
javaProcess.stderr?.on('data', (data: Buffer) => {
const line = data.toString().trim()
if (line) console.error(`[Java:ERR] ${line}`)
})
javaProcess.on('error', (err: Error) => {
console.error('[MateClaw] Failed to start Java process:', err)
sendToWindow('backend:crashed', `Java 进程启动失败: ${err.message}`)
})
javaProcess.on('exit', (code: number | null, signal: string | null) => {
console.log(`[MateClaw] Java process exited: code=${code}, signal=${signal}`)
javaProcess = null
if (!isQuitting) {
sendToWindow('backend:crashed', `Java 进程意外退出 (code: ${code})`)
}
})
// Start health check polling
pollBackendReady()
}
function pollBackendReady(): void {
const startTime = Date.now()
sendToWindow('backend:status', 'starting')
let resolved = false
const check = () => {
if (isQuitting || resolved) return
const elapsed = Date.now() - startTime
// Remote connections fail fast (server should already be up); the embedded
// JVM gets the full window to boot.
const timeout = connectionMode === 'remote' ? 15_000 : HEALTH_CHECK_TIMEOUT
if (elapsed > timeout) {
console.error('[MateClaw] Backend health check timed out')
sendToWindow('backend:status', 'timeout')
if (connectionMode !== 'remote') {
dialog.showErrorBox(
'MateClaw 启动超时',
'后端服务启动超时,请检查日志或重启应用。'
)
}
return
}
const isHttps = BACKEND_URL.startsWith('https:')
const client = isHttps ? https : http
const reqOpts = isHttps ? { agent: insecureAgent } : {}
const req = client.get(`${BACKEND_URL}/`, reqOpts, (res) => {
if (resolved) return
resolved = true
// Consume response data to free up the socket
res.resume()
backendReady = true
console.log(`[MateClaw] Backend ready (${elapsed}ms, status: ${res.statusCode})`)
sendToWindow('backend:status', 'ready')
// Do NOT auto-navigate — let the splash screen handle it
// after language selection / setup check completes.
})
req.on('error', () => {
if (resolved) return
// Server not ready yet, retry
setTimeout(check, HEALTH_CHECK_INTERVAL)
})
req.setTimeout(3000, () => {
req.destroy()
if (resolved) return
setTimeout(check, HEALTH_CHECK_INTERVAL)
})
}
check()
}
async function stopJavaBackend(): Promise<void> {
if (!javaProcess) return
console.log('[MateClaw] Stopping Java backend...')
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
console.log('[MateClaw] Force killing Java process')
javaProcess?.kill('SIGKILL')
resolve()
}, 10_000) // 10s grace period
javaProcess!.on('exit', () => {
clearTimeout(timeout)
console.log('[MateClaw] Java process stopped')
resolve()
})
// Try graceful shutdown first
if (process.platform === 'win32') {
// On Windows, spawn taskkill for graceful stop
spawn('taskkill', ['/pid', String(javaProcess!.pid), '/t'])
} else {
javaProcess!.kill('SIGTERM')
}
})
}
// ─── Connection Orchestration ────────────────────────────────────────────────
// Decide how to reach the backend on launch based on saved configuration.
async function bootConnection(): Promise<void> {
if (forceChooser) {
sendToWindow('backend:status', 'choose')
return
}
const cfg = loadConfig()
if (cfg.mode === 'local') {
connectionMode = 'local'
await startJavaBackend()
} else if (cfg.mode === 'remote' && cfg.remoteUrl) {
startRemoteConnection(cfg.remoteUrl)
} else {
// First run: the renderer queries getConnectionConfig() and shows the chooser.
connectionMode = null
sendToWindow('backend:status', 'choose')
}
}
// Point the shell at a remote server and start health polling against it.
function startRemoteConnection(url: string): void {
const normalized = normalizeServerUrl(url)
if (!normalized) {
sendToWindow('backend:crashed', `无效的服务器地址: ${url}`)
return
}
connectionMode = 'remote'
backendReady = false
BACKEND_URL = normalized
console.log(`[MateClaw] Remote mode → ${BACKEND_URL}`)
pollBackendReady()
}
// Probe an arbitrary server root with a short timeout. Used by the connection
// chooser's "Test" button before the user commits to a server.
function probeServer(
url: string,
timeoutMs = 6000
): Promise<{ ok: boolean; status?: number; error?: string }> {
return new Promise((resolve) => {
const normalized = normalizeServerUrl(url)
if (!normalized) {
resolve({ ok: false, error: 'invalid-url' })
return
}
const isHttps = normalized.startsWith('https:')
const client = isHttps ? https : http
const reqOpts = isHttps ? { agent: insecureAgent } : {}
const req = client.get(`${normalized}/`, reqOpts, (res) => {
res.resume()
const status = res.statusCode ?? 0
// Any non-5xx response means the server is reachable and serving.
resolve({ ok: status > 0 && status < 500, status })
})
req.on('error', (err) => resolve({ ok: false, error: err.message }))
req.setTimeout(timeoutMs, () => {
req.destroy()
resolve({ ok: false, error: 'timeout' })
})
})
}
// Reload the splash and force the connection chooser (menu "Switch Server").
function goToConnectionChooser(): void {
forceChooser = true
backendReady = false
loadSplash()
}
function loadSplash(): void {
if (!mainWindow || mainWindow.isDestroyed()) return
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../../dist/index.html'))
}
}
// ─── Window Management ───────────────────────────────────────────────────────
function createWindow(): void {
const preloadPath = join(__dirname, '../preload/index.js')
mainWindow = new BrowserWindow({
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
minWidth: 900,
minHeight: 600,
title: 'MateClaw',
icon: join(__dirname, '../../build/icon.png'),
webPreferences: {
preload: preloadPath,
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
},
show: false,
backgroundColor: '#f5f5f5',
})
// Show when ready to prevent visual flash
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
})
// Open DevTools in dev mode for debugging
if (!app.isPackaged) {
mainWindow.webContents.openDevTools({ mode: 'detach' })
}
// Log renderer console messages to main process
mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => {
const levelStr = ['DEBUG', 'INFO', 'WARN', 'ERROR'][level] || 'LOG'
console.log(`[Renderer:${levelStr}] ${message} (${sourceId}:${line})`)
})
// Load splash screen first
loadSplash()
// Open external links in system browser, but allow WeCom auth popup in-app
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// WeCom SDK needs in-app popup for postMessage auth callback
if (url.includes('work.weixin.qq.com')) {
return {
action: 'allow',
overrideBrowserWindowOptions: {
width: 500,
height: 620,
title: '企业微信授权',
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
},
}
}
if (url.startsWith('http')) {
shell.openExternal(url)
}
return { action: 'deny' }
})
mainWindow.on('closed', () => {
mainWindow = null
})
}
function sendToWindow(channel: string, data: unknown): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, data)
}
}
// ─── Auto Updater ───────────────────────────────────────────────────────────
function setupAutoUpdater(): void {
if (!app.isPackaged) {
// In dev mode, electron-updater can still work if dev-app-update.yml exists
// at the project root. It overrides the publish config from electron-builder.json.
const devUpdateConfig = resolve(__dirname, '../../dev-app-update.yml')
if (!existsSync(devUpdateConfig)) {
console.log('[MateClaw] Skipping auto-updater in dev mode (no dev-app-update.yml)')
return
}
console.log('[MateClaw] Dev mode: using dev-app-update.yml for updater')
autoUpdater.forceDevUpdateConfig = true
}
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
autoUpdater.on('checking-for-update', () => {
updaterState = { status: 'checking' }
sendToWindow('updater:state', updaterState)
console.log('[MateClaw] Checking for update...')
})
autoUpdater.on('update-available', (info: UpdateInfo) => {
updaterState = {
status: 'available',
version: info.version,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
}
sendToWindow('updater:state', updaterState)
console.log(`[MateClaw] Update available: ${info.version}`)
})
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
updaterState = { status: 'not-available', version: info.version }
sendToWindow('updater:state', updaterState)
console.log('[MateClaw] No update available')
})
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
updaterState = {
...updaterState,
status: 'downloading',
progress: {
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
},
}
sendToWindow('updater:state', updaterState)
})
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
updaterState = { status: 'downloaded', version: info.version }
sendToWindow('updater:state', updaterState)
console.log(`[MateClaw] Update downloaded: ${info.version}`)
})
autoUpdater.on('error', (err: Error) => {
updaterState = { status: 'error', error: err.message }
sendToWindow('updater:state', updaterState)
console.error('[MateClaw] Auto-updater error:', err.message)
setTimeout(() => {
if (updaterState.status === 'error') {
updaterState = { status: 'idle' }
sendToWindow('updater:state', updaterState)
}
}, 10_000)
})
// Check for updates after a short delay to avoid blocking startup
setTimeout(() => {
autoUpdater.checkForUpdates().catch((err) => {
console.error('[MateClaw] Update check failed:', err.message)
})
}, 3000)
}
// ─── IPC Handlers ────────────────────────────────────────────────────────────
function registerIpcHandlers(): void {
ipcMain.handle('app:get-platform', () => process.platform)
ipcMain.handle('app:get-version', () => app.getVersion())
ipcMain.handle('app:get-backend-url', () => BACKEND_URL)
ipcMain.handle('app:is-backend-ready', () => backendReady)
ipcMain.handle('app:open-external', (_event, url: string) => {
shell.openExternal(url)
})
ipcMain.handle('app:get-user-data-path', () => app.getPath('userData'))
ipcMain.handle('app:restart-backend', async () => {
backendReady = false
sendToWindow('backend:status', 'restarting')
if (connectionMode === 'remote') {
// Nothing to restart locally — just re-probe the remote server.
startRemoteConnection(BACKEND_URL)
return
}
await stopJavaBackend()
await startJavaBackend()
})
// ── Connection management IPC ──
ipcMain.handle('connection:get-config', () => {
const cfg = loadConfig()
return {
mode: cfg.mode,
remoteUrl: cfg.remoteUrl,
servers: cfg.servers,
// The renderer shows the chooser on first run or when "Switch Server" forced it.
forceChoose: forceChooser,
}
})
ipcMain.handle('connection:test', async (_event, url: string) => {
return probeServer(url)
})
ipcMain.handle('connection:use-local', async () => {
forceChooser = false
saveConfig({ mode: 'local' })
connectionMode = 'local'
backendReady = false
if (!javaProcess) {
await startJavaBackend()
} else {
// Already running (e.g. switched away and back) — just re-check health.
pollBackendReady()
}
})
ipcMain.handle('connection:use-remote', async (_event, url: string) => {
const normalized = normalizeServerUrl(url)
if (!normalized) return { ok: false, error: 'invalid-url' }
forceChooser = false
// A local JVM is pointless in remote mode — free its resources.
if (javaProcess) {
await stopJavaBackend()
}
saveConfig({ mode: 'remote', remoteUrl: normalized })
recordServer(normalized)
startRemoteConnection(normalized)
return { ok: true }
})
ipcMain.handle('connection:switch-server', () => {
goToConnectionChooser()
})
ipcMain.handle('app:navigate-to-app', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
console.log('[MateClaw] Navigating to main application')
mainWindow.loadURL(BACKEND_URL)
}
})
// ── Auto Updater IPC ──
ipcMain.handle('updater:get-state', () => updaterState)
ipcMain.handle('updater:check', async () => {
if (!app.isPackaged) return { status: 'not-available' } as UpdaterState
try {
await autoUpdater.checkForUpdates()
} catch (err: any) {
console.error('[MateClaw] Manual update check failed:', err.message)
}
return updaterState
})
ipcMain.handle('updater:download', async () => {
if (updaterState.status !== 'available') return
try {
await autoUpdater.downloadUpdate()
} catch (err: any) {
console.error('[MateClaw] Download failed:', err.message)
}
})
ipcMain.handle('updater:install', async () => {
if (updaterState.status !== 'downloaded') return
console.log('[MateClaw] Installing update, stopping backend first...')
isUpdating = true
try {
await stopJavaBackend()
} catch (err) {
console.error('[MateClaw] Error stopping backend before update:', err)
}
autoUpdater.quitAndInstall(false, true)
})
}
// ─── App Lifecycle ───────────────────────────────────────────────────────────
// Prevent multiple instances
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
}
// ─── Application Menu ────────────────────────────────────────────────────────
function showAboutDialog(): void {
const iconPath = join(__dirname, '../../build/icon.png')
const icon = existsSync(iconPath) ? nativeImage.createFromPath(iconPath) : undefined
dialog.showMessageBox({
type: 'info',
title: 'About MateClaw',
message: 'MateClaw',
detail: [
`Version: ${app.getVersion()}`,
'',
'Your intelligent AI assistant powered by Spring AI Alibaba.',
'',
`Copyright © 2026 MateClaw Team`,
].join('\n'),
buttons: ['OK'],
icon,
})
}
async function menuCheckForUpdates(): Promise<void> {
if (!app.isPackaged) {
dialog.showMessageBox({ type: 'info', message: 'Update check is not available in dev mode.' })
return
}
try {
const result = await autoUpdater.checkForUpdates()
if (!result || !result.updateInfo || result.updateInfo.version === app.getVersion()) {
dialog.showMessageBox({
type: 'info',
title: 'Check for Updates',
message: 'You are up to date!',
detail: `MateClaw ${app.getVersion()} is the latest version.`,
})
}
// If update is available, the existing updater:state IPC events will notify the renderer
} catch (err: any) {
dialog.showMessageBox({
type: 'error',
title: 'Update Error',
message: 'Failed to check for updates',
detail: err.message || 'Please check your network connection and try again.',
})
}
}
function setupApplicationMenu(): void {
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = []
// ── macOS App Menu ──
if (isMac) {
template.push({
label: app.name,
submenu: [
{ label: `About ${app.name}`, click: showAboutDialog },
{ label: 'Check for Updates...', click: menuCheckForUpdates },
{ type: 'separator' },
{ label: 'Switch Server…', click: goToConnectionChooser },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
})
}
// ── File Menu (Windows/Linux only) ──
if (!isMac) {
template.push({
label: 'File',
submenu: [
{ label: 'Switch Server…', click: goToConnectionChooser },
{ type: 'separator' },
{ role: 'quit', label: 'Exit' },
],
})
}
// ── Edit Menu ──
template.push({
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
})
// ── View Menu ──
template.push({
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
})
// ── Window Menu (macOS) ──
if (isMac) {
template.push({
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
{ type: 'separator' },
{ role: 'front' },
],
})
}
// ── Help Menu ──
template.push({
label: 'Help',
submenu: [
...(!isMac ? [
{ label: 'Check for Updates...', click: menuCheckForUpdates },
{ type: 'separator' as const },
] : []),
{
label: 'GitHub Repository',
click: () => shell.openExternal('https://github.com/matevip/mateclaw'),
},
{
label: 'Report Issue',
click: () => shell.openExternal('https://github.com/matevip/mateclaw/issues'),
},
...(!isMac ? [
{ type: 'separator' as const },
{ label: `About ${app.name}`, click: showAboutDialog },
] : []),
],
})
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
}
// Allow the user to accept a self-signed / untrusted certificate for the remote
// server they explicitly chose to connect to (common on enterprise intranets).
app.on('certificate-error', (event, _webContents, url, _error, _certificate, callback) => {
let host = ''
try {
host = new URL(url).host
} catch {
callback(false)
return
}
if (trustedCertHosts.has(host)) {
event.preventDefault()
callback(true)
return
}
// Only prompt for the server the user is actively connecting to.
if (connectionMode !== 'remote' || !BACKEND_URL.includes(host)) {
callback(false)
return
}
const choice = dialog.showMessageBoxSync({
type: 'warning',
title: '证书不受信任',
message: `服务器 ${host} 使用了不受信任的证书`,
detail: '该服务器的 TLS 证书无法验证(可能是自签名证书)。仅在你信任此服务器时继续。',
buttons: ['取消', '信任并继续'],
defaultId: 0,
cancelId: 0,
})
if (choice === 1) {
trustedCertHosts.add(host)
event.preventDefault()
callback(true)
} else {
callback(false)
}
})
app.whenReady().then(() => {
setupApplicationMenu()
registerIpcHandlers()
createWindow()
bootConnection()
setupAutoUpdater()
})
app.on('window-all-closed', () => {
// On macOS, apps typically stay active until Cmd+Q
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
// On macOS, re-create window when dock icon is clicked
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
if (!backendReady) {
bootConnection()
}
}
})
app.on('before-quit', async (event) => {
if (isQuitting) return
// During update install, backend is already stopped by updater:install handler
if (isUpdating) {
isQuitting = true
return
}
isQuitting = true
event.preventDefault()
try {
await stopJavaBackend()
} catch (err) {
console.error('[MateClaw] Error stopping backend:', err)
} finally {
app.exit(0)
}
})

View File

@ -0,0 +1,48 @@
import { contextBridge, ipcRenderer } from 'electron'
// Expose safe APIs to the renderer process (splash screen)
contextBridge.exposeInMainWorld('mateClawAPI', {
// Platform info
getPlatform: () => ipcRenderer.invoke('app:get-platform'),
getVersion: () => ipcRenderer.invoke('app:get-version'),
getBackendUrl: () => ipcRenderer.invoke('app:get-backend-url'),
isBackendReady: () => ipcRenderer.invoke('app:is-backend-ready'),
getUserDataPath: () => ipcRenderer.invoke('app:get-user-data-path'),
// Actions
openExternal: (url: string) => ipcRenderer.invoke('app:open-external', url),
restartBackend: () => ipcRenderer.invoke('app:restart-backend'),
navigateToApp: () => ipcRenderer.invoke('app:navigate-to-app'),
// Connection management
getConnectionConfig: () => ipcRenderer.invoke('connection:get-config'),
testConnection: (url: string) => ipcRenderer.invoke('connection:test', url),
useLocalConnection: () => ipcRenderer.invoke('connection:use-local'),
useRemoteConnection: (url: string) => ipcRenderer.invoke('connection:use-remote', url),
switchServer: () => ipcRenderer.invoke('connection:switch-server'),
// Backend status events
onBackendStatus: (callback: (status: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, status: string) => callback(status)
ipcRenderer.on('backend:status', handler)
return () => ipcRenderer.removeListener('backend:status', handler)
},
onBackendCrashed: (callback: (message: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, message: string) => callback(message)
ipcRenderer.on('backend:crashed', handler)
return () => ipcRenderer.removeListener('backend:crashed', handler)
},
// Auto-updater
getUpdaterState: () => ipcRenderer.invoke('updater:get-state'),
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
installUpdate: () => ipcRenderer.invoke('updater:install'),
onUpdaterState: (callback: (state: any) => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: any) => callback(state)
ipcRenderer.on('updater:state', handler)
return () => ipcRenderer.removeListener('updater:state', handler)
},
})

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MateClaw</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
-webkit-app-region: drag;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,36 @@
{
"name": "mateclaw-desktop",
"version": "1.7.0-SNAPSHOT",
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
"author": "MateClaw Team",
"license": "Apache-2.0",
"main": "dist-electron/main/index.js",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"setup:jre": "bash scripts/download-jre.sh",
"setup:jar": "bash scripts/build.sh",
"setup": "npm run setup:jar && npm run setup:jre",
"setup:all-platforms": "bash scripts/build-all-platforms.sh --all",
"package:mac": "npm run build && electron-builder --mac",
"package:win": "npm run build && electron-builder --win",
"package:all": "bash scripts/build-all-platforms.sh --all",
"publish:github": "bash scripts/publish-github.sh",
"publish:github:draft": "bash scripts/publish-github.sh --draft"
},
"dependencies": {
"electron-updater": "^6.3.9",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"electron": "^33.3.1",
"electron-builder": "^25.1.8",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6",
"vue-tsc": "^2.2.0"
}
}

3851
mateclaw-desktop/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

1321
mateclaw-desktop/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

65
mateclaw-desktop/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,65 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface UpdaterState {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
version?: string
releaseNotes?: string
progress?: { percent: number; bytesPerSecond: number; transferred: number; total: number }
error?: string
}
interface RemoteServer {
url: string
name?: string
lastUsed?: number
}
interface ConnectionConfigState {
mode: 'local' | 'remote' | null
remoteUrl: string
servers: RemoteServer[]
forceChoose: boolean
}
interface ConnectionTestResult {
ok: boolean
status?: number
error?: string
}
interface MateClawAPI {
getPlatform: () => Promise<string>
getVersion: () => Promise<string>
getBackendUrl: () => Promise<string>
isBackendReady: () => Promise<boolean>
getUserDataPath: () => Promise<string>
openExternal: (url: string) => Promise<void>
restartBackend: () => Promise<void>
onBackendStatus: (callback: (status: string) => void) => () => void
onBackendCrashed: (callback: (message: string) => void) => () => void
navigateToApp: () => void
// Connection management
getConnectionConfig: () => Promise<ConnectionConfigState>
testConnection: (url: string) => Promise<ConnectionTestResult>
useLocalConnection: () => Promise<void>
useRemoteConnection: (url: string) => Promise<ConnectionTestResult>
switchServer: () => Promise<void>
// Auto-updater
getUpdaterState: () => Promise<UpdaterState>
checkForUpdates: () => Promise<UpdaterState>
downloadUpdate: () => Promise<void>
installUpdate: () => Promise<void>
onUpdaterState: (callback: (state: UpdaterState) => void) => () => void
}
interface Window {
mateClawAPI: MateClawAPI
}

View File

@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"resolveJsonModule": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "electron/**/*.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"composite": true,
"skipLibCheck": true,
"noEmit": false
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,60 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import electron from 'vite-plugin-electron'
import renderer from 'vite-plugin-electron-renderer'
import { resolve } from 'path'
export default defineConfig(({ command }) => {
const isServe = command === 'serve'
const isBuild = command === 'build'
return {
plugins: [
vue(),
electron([
{
entry: 'electron/main/index.ts',
onstart(args) {
args.startup()
},
vite: {
build: {
sourcemap: isServe,
minify: isBuild,
outDir: 'dist-electron/main',
rollupOptions: {
external: ['electron', 'electron-updater'],
},
},
},
},
{
entry: 'electron/preload/index.ts',
onstart(args) {
args.reload()
},
vite: {
build: {
sourcemap: isServe ? 'inline' : undefined,
minify: isBuild,
outDir: 'dist-electron/preload',
rollupOptions: {
external: ['electron'],
},
},
},
},
]),
renderer(),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
}
})