mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(desktop): open-source the Electron desktop app (build, electron main/preload, renderer, config)
This commit is contained in:
parent
e4e7b4c377
commit
b563f93c1d
15
mateclaw-desktop/.env.example
Normal file
15
mateclaw-desktop/.env.example
Normal 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
33
mateclaw-desktop/.gitignore
vendored
Normal 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
|
||||
275
mateclaw-desktop/CODESIGNING.md
Normal file
275
mateclaw-desktop/CODESIGNING.md
Normal 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
432
mateclaw-desktop/README.md
Normal 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 Screen(Vue 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. 打包 macOS(DMG + ZIP)和 Windows(NSIS)
|
||||
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 代码签名证书
|
||||
|
||||
推荐使用 EV(Extended 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 运行时
|
||||
67
mateclaw-desktop/RELEASE_NOTES_v1.0.101.md
Normal file
67
mateclaw-desktop/RELEASE_NOTES_v1.0.101.md
Normal 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
|
||||
14
mateclaw-desktop/build/entitlements.mac.inherit.plist
Normal file
14
mateclaw-desktop/build/entitlements.mac.inherit.plist
Normal 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>
|
||||
18
mateclaw-desktop/build/entitlements.mac.plist
Normal file
18
mateclaw-desktop/build/entitlements.mac.plist
Normal 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>
|
||||
BIN
mateclaw-desktop/build/icon.icns
Normal file
BIN
mateclaw-desktop/build/icon.icns
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
BIN
mateclaw-desktop/build/icon.ico
Normal file
BIN
mateclaw-desktop/build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
mateclaw-desktop/build/icon.png
Normal file
BIN
mateclaw-desktop/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
BIN
mateclaw-desktop/build/icon_256.png
Normal file
BIN
mateclaw-desktop/build/icon_256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
96
mateclaw-desktop/electron-builder.json
Normal file
96
mateclaw-desktop/electron-builder.json
Normal 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}"
|
||||
}
|
||||
}
|
||||
87
mateclaw-desktop/electron/main/config.ts
Normal file
87
mateclaw-desktop/electron/main/config.ts
Normal 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 })
|
||||
}
|
||||
920
mateclaw-desktop/electron/main/index.ts
Normal file
920
mateclaw-desktop/electron/main/index.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
48
mateclaw-desktop/electron/preload/index.ts
Normal file
48
mateclaw-desktop/electron/preload/index.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
27
mateclaw-desktop/index.html
Normal file
27
mateclaw-desktop/index.html
Normal 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>
|
||||
36
mateclaw-desktop/package.json
Normal file
36
mateclaw-desktop/package.json
Normal 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
3851
mateclaw-desktop/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
mateclaw-desktop/public/logo/mateclaw_logo_s.png
Normal file
BIN
mateclaw-desktop/public/logo/mateclaw_logo_s.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
1321
mateclaw-desktop/src/App.vue
Normal file
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
65
mateclaw-desktop/src/env.d.ts
vendored
Normal 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
|
||||
}
|
||||
4
mateclaw-desktop/src/main.ts
Normal file
4
mateclaw-desktop/src/main.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
25
mateclaw-desktop/tsconfig.json
Normal file
25
mateclaw-desktop/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
12
mateclaw-desktop/tsconfig.node.json
Normal file
12
mateclaw-desktop/tsconfig.node.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
mateclaw-desktop/tsconfig.node.tsbuildinfo
Normal file
1
mateclaw-desktop/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
60
mateclaw-desktop/vite.config.ts
Normal file
60
mateclaw-desktop/vite.config.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user