diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml new file mode 100644 index 0000000..9621169 --- /dev/null +++ b/.github/workflows/build-app.yml @@ -0,0 +1,76 @@ +name: Build Desktop App + +on: + push: + branches: + - main + tags: + - "v*" + paths: + - ".github/workflows/build-app.yml" + - "electron.vite.config.ts" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "docs/icon.png" + - "src/**" + pull_request: + paths: + - ".github/workflows/build-app.yml" + - "electron.vite.config.ts" + - "package.json" + - "package-lock.json" + - "tsconfig.json" + - "docs/icon.png" + - "src/**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows x64 + os: windows-2025 + command: npm run dist:win + artifact: topspeed-builder-windows-x64 + - name: macOS arm64 + os: macos-15 + command: npm run dist:mac:arm64 + artifact: topspeed-builder-macos-arm64 + - name: macOS Intel + os: macos-15-intel + command: npm run dist:mac:x64 + artifact: topspeed-builder-macos-x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Package app + run: ${{ matrix.command }} + env: + CSC_IDENTITY_AUTO_DISCOVERY: false + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: release/**/* + if-no-files-found: error + retention-days: 14 diff --git a/README.md b/README.md index f4d0b92..afbe125 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,35 @@ npm run dev # 开发模式 npm run typecheck # 类型检查 npm run build # 构建到 out/ npm run dist:win # 打包 Windows 安装包(输出到 release/) +npm run dist:mac:arm64 # 在 Apple Silicon Mac 上打包 DMG/ZIP +npm run dist:mac:x64 # 在 Intel Mac 上打包 DMG/ZIP ``` 一键安装包位于 `release/Topspeed Builder Setup 1.0.3.exe`。 +macOS 内测包位于 `release/Topspeed Builder-1.0.3-mac-arm64.dmg` 或 `release/Topspeed Builder-1.0.3-mac-x64.dmg`。 + +macOS 打包需要在 macOS 环境执行。CI 会通过 `.github/workflows/build-app.yml` 产出 Windows、macOS arm64 和 macOS x64 内测包;正式分发前需要 Apple Developer ID 签名和公证,流程见 [macOS Build and Release](./docs/mac-release.md)。 + +### macOS 命令行运行 + +如果暂时没有 macOS 安装包,Mac 用户可以从源码启动: + +```bash +git clone https://github.com/voicepeak/topspeed-builder.git +cd topspeed-builder +git checkout macos +npm ci +npm run dev +``` + +也可以使用生产构建预览: + +```bash +npm run build +npm start +``` + +建议使用 Node.js 22。若安装原生依赖失败,先执行 `xcode-select --install` 安装 Xcode Command Line Tools。 ## 智能生成接口配置 @@ -181,9 +207,35 @@ npm run dev # dev mode npm run typecheck # type checking npm run build # compile to out/ npm run dist:win # package Windows installer (output in release/) +npm run dist:mac:arm64 # package Apple Silicon DMG/ZIP on macOS +npm run dist:mac:x64 # package Intel Mac DMG/ZIP on macOS ``` One-click installer at `release/Topspeed Builder Setup 1.0.3.exe`. +macOS test artifacts are written to `release/Topspeed Builder-1.0.3-mac-arm64.dmg` or `release/Topspeed Builder-1.0.3-mac-x64.dmg`. + +macOS packaging must run on macOS. The CI workflow at `.github/workflows/build-app.yml` builds Windows, macOS arm64, and macOS x64 test artifacts. Public macOS distribution requires Apple Developer ID signing and notarization; see [macOS Build and Release](./docs/mac-release.md). + +### macOS CLI Run + +If a macOS installer is not available yet, Mac users can run the app from source: + +```bash +git clone https://github.com/voicepeak/topspeed-builder.git +cd topspeed-builder +git checkout macos +npm ci +npm run dev +``` + +Production preview is also available: + +```bash +npm run build +npm start +``` + +Node.js 22 is recommended. If native dependency installation fails, install Xcode Command Line Tools with `xcode-select --install`. ## AI API Configuration diff --git a/docs/mac-release.md b/docs/mac-release.md new file mode 100644 index 0000000..3bde5be --- /dev/null +++ b/docs/mac-release.md @@ -0,0 +1,61 @@ +# macOS Build and Release + +This project supports macOS builds through electron-builder. Build macOS artifacts on macOS runners because the app uses `sharp`, a native dependency with platform-specific binaries. + +## Internal Test Builds + +Apple Silicon: + +```bash +npm ci +npm run dist:mac:arm64 +``` + +Intel Mac: + +```bash +npm ci +npm run dist:mac:x64 +``` + +Artifacts are written to `release/`: + +- `Topspeed Builder--mac-arm64.dmg` +- `Topspeed Builder--mac-arm64.zip` +- `Topspeed Builder--mac-x64.dmg` +- `Topspeed Builder--mac-x64.zip` + +The GitHub Actions workflow at `.github/workflows/build-app.yml` produces unsigned Windows and macOS artifacts for CI validation and internal testing. + +## Smoke Test Checklist + +Run this checklist on both Apple Silicon and Intel builds before publishing: + +1. Launch the app from the DMG. +2. Create a project under `Documents/Topspeed Builder Projects`. +3. Import PNG/JPG/WebP reference images. +4. Switch provider to `local-draft` and generate an icon, a character sheet, and a tileset. +5. Use "show in folder" from an asset card. +6. Export with ZIP enabled and open the export directory. +7. Reopen the app and confirm the recent project list loads. + +## Signed Distribution + +Unsigned artifacts are suitable only for internal testing. Public macOS distribution should be signed with a Developer ID Application certificate and notarized by Apple. + +Required GitHub Actions secrets for signed builds: + +- `CSC_LINK`: Base64-encoded `.p12` certificate or a secure URL to it. +- `CSC_KEY_PASSWORD`: Password for the `.p12` certificate. +- `APPLE_ID`: Apple Developer account email. +- `APPLE_APP_SPECIFIC_PASSWORD`: App-specific password for notarization. +- `APPLE_TEAM_ID`: Apple Developer Team ID. + +Signed scripts: + +```bash +npm run dist:mac:signed:arm64 +npm run dist:mac:signed:x64 +``` + +After secrets are configured, run the signed scripts on macOS release runners and publish the notarized DMG/ZIP files. diff --git a/package.json b/package.json index 8bc45cd..fc36e41 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "dist": "npm run build && electron-builder", "dist:win": "npm run build && electron-builder --win", "dist:mac": "npm run build && electron-builder --mac", + "dist:mac:arm64": "npm run build && electron-builder --mac --arm64", + "dist:mac:x64": "npm run build && electron-builder --mac --x64", + "dist:mac:signed:arm64": "npm run build && electron-builder --mac --arm64 -c.mac.notarize=true", + "dist:mac:signed:x64": "npm run build && electron-builder --mac --x64 -c.mac.notarize=true", "dist:linux": "npm run build && electron-builder --linux", "typecheck": "tsc --noEmit" }, @@ -48,7 +52,9 @@ }, "mac": { "target": ["dmg", "zip"], - "icon": "docs/icon.png" + "icon": "docs/icon.png", + "category": "public.app-category.graphics-design", + "artifactName": "${productName}-${version}-mac-${arch}.${ext}" }, "linux": { "target": ["AppImage", "deb"], diff --git a/src/main/index.ts b/src/main/index.ts index 8b44c1e..ac94ccf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -103,6 +103,11 @@ function registerIpc(): void { return true; }); + handle("shell:showProjectItem", async (projectPath: string, filePath: string) => { + shell.showItemInFolder(resolveProjectPath(projectPath, filePath)); + return true; + }); + handle("shell:openPath", async (filePath: string) => { const result = await shell.openPath(filePath); if (result) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 4b536e8..49ab426 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,5 +26,7 @@ contextBridge.exposeInMainWorld("topspeedBuilder", { ipcRenderer.invoke("image:dataUrl", projectPath, filePath), deleteAsset: (projectPath: string, assetId: string) => ipcRenderer.invoke("project:deleteAsset", projectPath, assetId), showItemInFolder: (filePath: string) => ipcRenderer.invoke("shell:showItem", filePath), + showProjectItemInFolder: (projectPath: string, filePath: string) => + ipcRenderer.invoke("shell:showProjectItem", projectPath, filePath), openPath: (filePath: string) => ipcRenderer.invoke("shell:openPath", filePath) }); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1bfc768..555e1c4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1586,7 +1586,7 @@ function AssetCard(props: { onClick={() => { const file = props.asset.metadataPath ?? pngFile; if (file) { - void props.runTask(t("busy.locateFile"), () => unwrap(window.topspeedBuilder.showItemInFolder(resolveFile(props.project.path, file)))); + void props.runTask(t("busy.locateFile"), () => unwrap(window.topspeedBuilder.showProjectItemInFolder(props.project.path, file))); } }} > @@ -1874,13 +1874,6 @@ function gameTypeLabelT(t: (key: string) => string, gameType: string): string { return found ? t(selectOptionLabel(found)) : gameType; } -function resolveFile(projectPath: string, filePath: string): string { - if (/^[a-zA-Z]:[\\/]/.test(filePath) || filePath.startsWith("/")) { - return filePath; - } - return `${projectPath}\\${filePath.replace(/\//g, "\\")}`; -} - function formatQueueError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); const normalized = message.replace(/\s+/g, " ").trim(); diff --git a/src/renderer/src/vite-env.d.ts b/src/renderer/src/vite-env.d.ts index a889fe0..ec7aaf0 100644 --- a/src/renderer/src/vite-env.d.ts +++ b/src/renderer/src/vite-env.d.ts @@ -34,6 +34,7 @@ declare global { getHistory(projectPath: string): Promise>; readImageDataUrl(projectPath: string, filePath: string): Promise>; showItemInFolder(filePath: string): Promise>; + showProjectItemInFolder(projectPath: string, filePath: string): Promise>; openPath(filePath: string): Promise>; }; }