diff --git a/.env.example b/.env.example index 3a455ca..bb138f2 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,14 @@ DATABASE_PATH=./data/acestep.db # Run ./models.sh to download the default Q8_0 essential set (~8 GB). MODELS_DIR=./models +# Electron desktop app: MODELS_DIR is resolved in this priority order: +# 1. MODELS_DIR env var set before launching (e.g. export MODELS_DIR=/my/models) +# 2. Path chosen via the "Browse for existing models…" dialog (saved in prefs.json) +# 3. Default: /models (macOS: ~/Library/Application Support/ACE-Step UI/models) +# (Linux: ~/.config/ACE-Step UI/models) +# Setting MODELS_DIR in the environment lets you reuse a shared models folder +# across multiple tools without copying or re-downloading the ~8 GB model set. + # ── acestep-cpp: two modes, pick ONE ───────────────────────────────────────── # # Mode 1 (recommended, zero-config): binaries are auto-discovered in ./bin/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4af0f0c..24a8385 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: cache: "npm" - name: Install dependencies - run: npm ci + run: npm install - name: TypeScript check run: ./node_modules/.bin/tsc --noEmit diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml new file mode 100644 index 0000000..8bd2781 --- /dev/null +++ b/.github/workflows/electron-release.yml @@ -0,0 +1,346 @@ +name: Electron Release + +# Triggered by a version tag push (v*) or a manual run. +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.0.0)" + required: true + default: "v0.0.1-electron" + binary_version: + description: "acestep.cpp release tag to bundle (e.g. v0.0.1)" + required: false + default: "v0.0.1" + +concurrency: + group: electron-release-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: "20" + # acestep.cpp binary release to bundle; override via workflow_dispatch input. + BINARY_VERSION: ${{ github.event.inputs.binary_version || 'v0.0.1' }} + +# ────────────────────────────────────────────────────────────────────────────── +# Shared setup steps are defined as a reusable composite action inline via +# `run` steps repeated in each job. Each job is self-contained so the CI +# log is easy to read and debug per platform. +# +# Archive layout (flat tarball — all files at ./): +# bin/ ace-qwen3, dit-vae, neural-codec, … +# bin/lib.so Linux shared libraries (unversioned names) +# bin/lib.dylib macOS dylibs (versioned + symlink chain) +# ────────────────────────────────────────────────────────────────────────────── + +jobs: + + # ──────────────────────────────────────────────────────────────────────────── + # macOS — Apple Silicon → ACE-Step UI-*.dmg + # ──────────────────────────────────────────────────────────────────────────── + build-mac: + name: Build — macOS arm64 + runs-on: macos-14 + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm ci + working-directory: server + + - name: Build frontend + run: npm run build + + - name: Build server + run: npm run build + working-directory: server + + - name: Download & extract acestep.cpp binaries + shell: bash + run: | + ARCHIVE="acestep-macos-arm64-metal.tar.gz" + URL="https://github.com/audiohacking/acestep.cpp/releases/download/${BINARY_VERSION}/${ARCHIVE}" + echo "Downloading ${ARCHIVE} from ${URL} …" + curl -fsSL --retry 3 "${URL}" -o "${ARCHIVE}" + mkdir -p bin + tar -xzf "${ARCHIVE}" -C bin/ + echo "bin/ contents:" + ls -lh bin/ + + - name: Verify macOS dylibs + shell: bash + run: | + # macOS dylibs ship as versioned files + two-level symlink chain: + # lib.0.9.7.dylib → lib.0.dylib → lib.dylib + GGML_VER="0.9.7" + warn=0 + for base in libggml libggml-base libggml-metal libggml-cpu libggml-blas; do + for name in "${base}.${GGML_VER}.dylib" "${base}.0.dylib" "${base}.dylib"; do + if [ -e "bin/${name}" ]; then echo "✅ bin/${name}" + else echo "⚠️ bin/${name} — missing"; warn=1; fi + done + done + for bin in ace-qwen3 dit-vae neural-codec; do + if [ -f "bin/${bin}" ] && [ -x "bin/${bin}" ]; then echo "✅ bin/${bin}" + else echo "⚠️ bin/${bin} — not found or not executable"; warn=1; fi + done + [ "$warn" = "0" ] || echo "⚠️ Some files missing — verify BINARY_VERSION=${BINARY_VERSION}" + + - name: Rebuild native modules for Electron + run: npx @electron/rebuild --module-dir server --only better-sqlite3 + + - name: Build Electron app bundle (unpacked) + # Build the unpacked .app directory so we can sign it before packaging. + # electron-builder --dir skips DMG creation; we create the DMG ourselves + # after signing so the delivered image contains a properly signed bundle. + run: npm run electron:build:mac -- --dir + env: + CSC_IDENTITY_AUTO_DISCOVERY: false # we sign manually below + + - name: Code sign the app bundle + shell: bash + run: | + APP="$(find release/mac-arm64 -maxdepth 1 -name '*.app' | head -1)" + if [ -z "$APP" ]; then + echo "❌ No .app bundle found in release/mac-arm64/" + ls -lh release/mac-arm64/ || true + exit 1 + fi + echo "App bundle: $APP" + chmod +x build/macos/codesign.sh + ./build/macos/codesign.sh "$APP" + env: + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY || '-' }} + + - name: Create DMG + shell: bash + run: | + APP="$(find release/mac-arm64 -maxdepth 1 -name '*.app' | head -1)" + APP_NAME="$(basename "$APP" .app)" + VERSION="$(node -p "require('./package.json').version")" + DMG_NAME="${APP_NAME}-${VERSION}-arm64.dmg" + + echo "Creating DMG: ${DMG_NAME}" + mkdir -p dist_dmg + cp -R "$APP" dist_dmg/ + # Add Applications symlink for drag-and-drop install + ln -sf /Applications dist_dmg/Applications + + hdiutil create \ + -volname "${APP_NAME}" \ + -srcfolder dist_dmg \ + -ov \ + -format UDZO \ + "release/${DMG_NAME}" + + echo "✅ DMG: release/${DMG_NAME}" + ls -lh "release/${DMG_NAME}" + + - name: Upload macOS artifact + uses: actions/upload-artifact@v4 + with: + name: electron-macos-arm64 + path: release/**/*.dmg + if-no-files-found: warn + retention-days: 7 + + # ──────────────────────────────────────────────────────────────────────────── + # Linux — x86_64 → ACE-Step UI-*.AppImage + ACE-Step UI-*.snap + # + # The Linux ELFs have a hardcoded RUNPATH pointing to the CI build tree. + # At runtime electron/main.js prepends BIN_DIR to LD_LIBRARY_PATH so the + # bundled shared libraries are found regardless. + # + # The archive ships unversioned .so names (libggml.so) but ELFs link against + # versioned sonames (libggml.so.0). We create the missing symlinks before + # packaging so electron-builder includes them in extraResources. + # ──────────────────────────────────────────────────────────────────────────── + build-linux: + name: Build — Linux x64 + runs-on: ubuntu-22.04 + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm ci + working-directory: server + + - name: Build frontend + run: npm run build + + - name: Build server + run: npm run build + working-directory: server + + - name: Download & extract acestep.cpp binaries + shell: bash + run: | + ARCHIVE="acestep-linux-x64.tar.gz" + URL="https://github.com/audiohacking/acestep.cpp/releases/download/${BINARY_VERSION}/${ARCHIVE}" + echo "Downloading ${ARCHIVE} from ${URL} …" + curl -fsSL --retry 3 "${URL}" -o "${ARCHIVE}" + mkdir -p bin + tar -xzf "${ARCHIVE}" -C bin/ + echo "bin/ contents:" + ls -lh bin/ + + - name: Create versioned soname symlinks + shell: bash + run: | + # ELFs link against libggml.so.0 / libggml-base.so.0 (sonames) but + # the archive ships the unversioned names. Create the missing links. + cd bin + for pair in "libggml.so:libggml.so.0" "libggml-base.so:libggml-base.so.0"; do + real="${pair%%:*}" + soname="${pair##*:}" + if [ -f "$real" ] && [ ! -e "$soname" ]; then + ln -sv "$real" "$soname" + fi + done + echo "Symlinks:" + ls -la | grep " -> " || echo "(none)" + + - name: Verify Linux binaries & libraries + shell: bash + run: | + warn=0 + for bin in ace-qwen3 dit-vae neural-codec; do + if [ -f "bin/${bin}" ] && [ -x "bin/${bin}" ]; then echo "✅ bin/${bin}" + else echo "⚠️ bin/${bin} — not found or not executable"; warn=1; fi + done + for lib in libggml.so libggml-base.so libggml.so.0 libggml-base.so.0; do + if [ -e "bin/${lib}" ]; then echo "✅ bin/${lib}" + else echo "⚠️ bin/${lib} — missing"; warn=1; fi + done + [ "$warn" = "0" ] || echo "⚠️ Some files missing — verify BINARY_VERSION=${BINARY_VERSION}" + + - name: Rebuild native modules for Electron + run: npx @electron/rebuild --module-dir server --only better-sqlite3 + + - name: Install snapcraft + run: sudo snap install snapcraft --classic + + - name: Build Electron package (Linux) + run: npm run electron:build:linux + env: + SNAPCRAFT_STORE_CREDENTIALS: "" # offline / no store upload + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 + with: + name: electron-linux-x64 + path: | + release/**/*.AppImage + release/**/*.snap + if-no-files-found: warn + retention-days: 7 + + # ──────────────────────────────────────────────────────────────────────────── + # Publish a GitHub Release with all platform artifacts attached. + # ──────────────────────────────────────────────────────────────────────────── + publish: + name: Publish GitHub Release + needs: [build-mac, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v4 + with: + pattern: electron-* + path: release-artifacts + merge-multiple: true + + - name: List release artifacts + run: find release-artifacts -type f | sort + + - name: Resolve release tag + id: tag + shell: bash + run: | + TAG="${GITHUB_REF_NAME:-}" + [[ "$TAG" == v* ]] || TAG="${{ github.event.inputs.tag || 'v0.0.1-electron' }}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: "ACE-Step UI ${{ steps.tag.outputs.tag }} — Desktop App" + draft: false + prerelease: ${{ contains(steps.tag.outputs.tag, '-') }} + body: | + ## ACE-Step UI ${{ steps.tag.outputs.tag }} — Electron Desktop App + + Native desktop application with embedded server and precompiled + [acestep.cpp](https://github.com/audiohacking/acestep.cpp) binaries bundled — no compiler or build step required. + + ### Downloads + + | File | Platform | + |------|----------| + | `*.dmg` | macOS — Apple Silicon (arm64) | + | `*.AppImage` | Linux — x86_64 (portable, any distro) | + | `*.snap` | Linux — x86_64 (Snap Store / snapd) | + + ### First run + + On first launch the app will offer to download the default Q8_0 model set + (~8 GB total) from HuggingFace automatically. You can also skip and + copy models manually to: + - **macOS** `~/Library/Application Support/ACE-Step UI/models/` + - **Linux** `~/.config/ACE-Step UI/models/` + + Generated audio is saved to `~/Music/ACEStep/`. + + ### macOS notes + + The DMG is ad-hoc signed (no Apple notarisation required for dev builds). + If macOS still shows a "damaged or incomplete" warning, run once: + ``` + xattr -cr "/Applications/ACE-Step UI.app" + ``` + + ### Linux notes + + **AppImage** — make executable then run: + ``` + chmod +x ACE-Step*.AppImage && ./ACE-Step*.AppImage + ``` + + **Snap** — install locally (classic confinement required): + ``` + sudo snap install *.snap --dangerous --classic + ``` + files: release-artifacts/**/* + fail_on_unmatched_files: false diff --git a/.gitignore b/.gitignore index 682f3c5..07853bb 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,8 @@ wavacity-editor/ # Script logs logs/ + +# Electron build output +release/ +dist-electron/ + diff --git a/docs/electron-local-testing.md b/docs/electron-local-testing.md new file mode 100644 index 0000000..6476efe --- /dev/null +++ b/docs/electron-local-testing.md @@ -0,0 +1,397 @@ +# Electron Build — Manual Pre-merge Test Instructions + +These steps let you validate the macOS and Linux Electron builds **locally** before merging, without waiting for CI to produce a full installer. They specifically verify: + +- The precompiled binary archive is correctly extracted into `bin/` +- All required `libggml` shared libraries are present alongside the binaries +- The symlink chain is intact (macOS `.0.dylib` / `.dylib`; Linux `.so.0`) +- `DYLD_LIBRARY_PATH` (macOS) or `LD_LIBRARY_PATH` (Linux) is correctly set to `bin/` at runtime +- The Electron app starts, shows the loading screen, and opens the UI +- First-run model dialog offers Download, Browse, and Skip options +- Browsing to an existing models folder persists the choice across restarts +- `MODELS_DIR` env var is respected and takes priority over saved preferences + +--- + +## Prerequisites + +| Tool | Minimum version | Check | +|------|----------------|-------| +| Node.js | 20 | `node -v` | +| npm | 10 | `npm -v` | +| curl | any | `curl --version` | +| macOS (Apple Silicon) | 14 (Sonoma) | `sw_vers` | +| Linux (x86_64) | any glibc ≥ 2.31 | `ldd --version` | + +Clone the branch and install dependencies: + +```bash +# After merge this will be on the main branch; use the appropriate branch/tag: +git clone https://github.com/audiohacking/acestep-cpp-ui +cd acestep-cpp-ui + +npm ci +npm ci --prefix server +``` + +--- + +## Step 1 — Download and extract the precompiled binaries + +Both archives are flat tarballs (no subdirectory). Run the command for your platform: + +### macOS (Apple Silicon) + +```bash +mkdir -p bin + +curl -fsSL --retry 3 \ + https://github.com/audiohacking/acestep.cpp/releases/download/v0.0.1/acestep-macos-arm64-metal.tar.gz \ + -o acestep-macos-arm64-metal.tar.gz + +echo "Archive contents:" +tar -tzf acestep-macos-arm64-metal.tar.gz + +echo "" +echo "Extracting …" +tar -xzf acestep-macos-arm64-metal.tar.gz -C bin/ + +echo "" +echo "bin/ after extraction:" +ls -lh bin/ +``` + +### Linux (x86_64) + +```bash +mkdir -p bin + +curl -fsSL --retry 3 \ + https://github.com/audiohacking/acestep.cpp/releases/download/v0.0.1/acestep-linux-x64.tar.gz \ + -o acestep-linux-x64.tar.gz + +echo "Archive contents:" +tar -tzf acestep-linux-x64.tar.gz + +echo "" +echo "Extracting …" +tar -xzf acestep-linux-x64.tar.gz -C bin/ + +echo "" +echo "bin/ after extraction:" +ls -lh bin/ +``` + +--- + +## Step 2 — Verify binaries and libraries are present + +### macOS — expected output + +Run: + +```bash +GGML_VER="0.9.7" +ok=1 +for bin in ace-qwen3 dit-vae neural-codec; do + [ -f "bin/$bin" ] && [ -x "bin/$bin" ] \ + && echo "✅ bin/$bin" \ + || { echo "❌ bin/$bin — missing or not executable"; ok=0; } +done +for lib_base in libggml libggml-base libggml-metal libggml-cpu libggml-blas; do + for lib_name in "${lib_base}.${GGML_VER}.dylib" "${lib_base}.0.dylib" "${lib_base}.dylib"; do + [ -e "bin/${lib_name}" ] \ + && echo "✅ bin/${lib_name}" \ + || { echo "❌ bin/${lib_name} — missing"; ok=0; } + done +done +[ "$ok" = "1" ] && echo "" && echo "All checks passed." || echo "" && echo "FAILURES — see above." +``` + +**Expected:** every line shows ✅. The archive ships the real `.0.9.7.dylib` files plus a two-level symlink chain (`.0.dylib` → `.0.9.7.dylib` and `.dylib` → `.0.dylib`). If any symlinks are missing, `fixMacosDylibLinks()` in `electron/main.js` will recreate them at app startup. + +### Linux — expected output + +Run: + +```bash +ok=1 +for bin in ace-qwen3 dit-vae neural-codec; do + [ -f "bin/$bin" ] && [ -x "bin/$bin" ] \ + && echo "✅ bin/$bin" \ + || { echo "❌ bin/$bin — missing or not executable"; ok=0; } +done +for lib in libggml.so libggml-base.so; do + [ -f "bin/$lib" ] \ + && echo "✅ bin/$lib" \ + || { echo "❌ bin/$lib — missing"; ok=0; } +done +[ "$ok" = "1" ] && echo "" && echo "All checks passed." || echo "" && echo "FAILURES — see above." +``` + +> **Note:** `libggml.so.0` / `libggml-base.so.0` symlinks are created by `fixSonameLinks()` at first run; at this point only the unversioned names need to be present. + +--- + +## Step 3 — Build the frontend and server + +```bash +# Frontend (Vite → dist/) +npm run build + +# Server (TypeScript → server/dist/) +npm run build --prefix server +``` + +Both should complete without errors. + +--- + +## Step 4 — Rebuild native modules for Electron + +```bash +npx @electron/rebuild --module-dir server --only better-sqlite3 +``` + +This rebuilds `better-sqlite3` against Electron's bundled Node.js ABI. Expect a success line like: +``` +✔ Rebuild Complete +``` + +--- + +## Step 5 — Run the Electron app in development mode + +```bash +npm run electron:dev +``` + +### What to observe + +| # | Expected behaviour | +|---|--------------------| +| 1 | A dark frameless **loading window** appears with a progress bar | +| 2 | Status text shows *"Checking models…"* **before** *"Starting server…"* (model check now runs first so the resolved path is passed to the server) | +| 3 | If **no `.gguf` files** exist in the models directory, a native dialog appears: *"ACE-Step models not found"* with **three** buttons: *"Download now (~8 GB)"*, *"Browse for existing models…"*, and *"Skip — I'll add models manually"* — click **Skip** for now | +| 4 | The main browser window opens at `http://127.0.0.1:3001` and shows the ACE-Step UI | +| 5 | No crash / black screen / *"library not found"* errors in the terminal | + +**Models directory locations:** + +| Platform | Path | +|----------|------| +| macOS | `~/Library/Application Support/ACE-Step UI/models/` | +| Linux | `~/.config/ACE-Step UI/models/` | + +--- + +## Step 6 — Verify library path environment variables + +While the app is running, open a second terminal and confirm the environment variables are set by checking the server process: + +### macOS + +```bash +# Find the ace-qwen3 / dit-vae child process (if a generation is running) or +# inspect the Electron main process env via the logs directory: +cat ~/Library/Application\ Support/ACE-Step\ UI/logs/server.log | grep -i "DYLD" +``` + +Alternatively, add a temporary `console.log` in `electron/main.js` after `setupLibraryPaths()`: +```js +console.log('DYLD_LIBRARY_PATH:', process.env.DYLD_LIBRARY_PATH); +``` +The value must contain the absolute path to `bin/` (dev mode: `/bin`). + +### Linux + +```bash +cat ~/.config/ACE-Step\ UI/logs/server.log | grep -i "LD_LIBRARY" +``` + +Or add temporarily: +```js +console.log('LD_LIBRARY_PATH:', process.env.LD_LIBRARY_PATH); +``` + +--- + +## Step 7 — Verify dylib / soname symlinks at runtime (macOS) + +After launching the app once, check that `fixMacosDylibLinks()` has created the symlinks if they were missing: + +```bash +ls -la bin/*.dylib | grep " -> " +``` + +Expected: 10 symlinks total (2 per library × 5 libraries): +``` +libggml-base.0.dylib -> libggml-base.0.9.7.dylib +libggml-base.dylib -> libggml-base.0.dylib +libggml-blas.0.dylib -> libggml-blas.0.9.7.dylib +libggml-blas.dylib -> libggml-blas.0.dylib +libggml-cpu.0.dylib -> libggml-cpu.0.9.7.dylib +libggml-cpu.dylib -> libggml-cpu.0.dylib +libggml-metal.0.dylib -> libggml-metal.0.9.7.dylib +libggml-metal.dylib -> libggml-metal.0.dylib +libggml.0.dylib -> libggml.0.9.7.dylib +libggml.dylib -> libggml.0.dylib +``` + +### Linux soname symlinks + +After first launch: + +```bash +ls -la bin/*.so* | grep " -> " +``` + +Expected: +``` +libggml.so.0 -> libggml.so +libggml-base.so.0 -> libggml-base.so +``` + +--- + +## Step 8 — Smoke test: trigger a generation + +1. With models present (or after downloading them), enter a short prompt such as `upbeat electronic music` +2. Set **Duration** to `5s` in the Advanced panel to keep the test fast +3. Click **Generate** +4. Watch the Debug tab — you should see `ace-qwen3` and `dit-vae` output lines appear +5. The generated audio file should appear in the Songs list and be playable + +If the binaries fail to load their shared libraries you will see an error like: +- macOS: `dyld: Library not loaded: @rpath/libggml.dylib` or `image not found` +- Linux: `error while loading shared libraries: libggml.so.0: cannot open shared object file` + +Either error means the library path setup is not working — check Steps 5–7 above. + +--- + +## Step 9 — (macOS) Test the packaged `.dmg` + +To fully validate the packaged release (as CI produces it): + +```bash +npm run electron:build:mac +``` + +Open `release/*.dmg`, mount it, and drag **ACE-Step UI.app** to `/Applications`. Launch it and repeat Steps 5–8. The packaged app uses `process.resourcesPath/bin` instead of the dev-mode `bin/` directory — `fixMacosDylibLinks()` recreates any symlinks electron-builder may have dropped. + +--- + +## Step 10 — Test the models path feature + +This step validates the new first-run UX for users who already have ACE-Step models locally. + +### 10a — Browse dialog + +1. Remove (or temporarily rename) the models directory so the app treats this as a first run: + + ```bash + # macOS + mv ~/Library/Application\ Support/ACE-Step\ UI/models \ + ~/Library/Application\ Support/ACE-Step\ UI/models.bak + + # Linux + mv ~/.config/ACE-Step\ UI/models ~/.config/ACE-Step\ UI/models.bak + ``` + +2. Clear any saved preference so the dialog triggers: + + ```bash + # macOS + rm -f ~/Library/Application\ Support/ACE-Step\ UI/prefs.json + + # Linux + rm -f ~/.config/ACE-Step\ UI/prefs.json + ``` + +3. Run the app: + + ```bash + npm run electron:dev + ``` + +4. The first-run dialog should appear with **three** buttons: + - `Download now (~8 GB)` + - `Browse for existing models…` + - `Skip — I'll add models manually` + +5. Click **Browse for existing models…** and navigate to the `.bak` folder (or any folder containing `.gguf` files). + +6. **Expected**: the app starts normally and the models folder in the loading status shows the path you selected. + +7. Quit and relaunch — the dialog should **not** appear again; the persisted path is used automatically. + +8. Verify the saved preference: + + ```bash + # macOS + cat ~/Library/Application\ Support/ACE-Step\ UI/prefs.json + + # Linux + cat ~/.config/ACE-Step\ UI/prefs.json + ``` + + Expected content: + ```json + { + "modelsDir": "/path/to/the/folder/you/selected" + } + ``` + +9. **Edge case — empty folder**: repeat from step 3, click Browse, and select a folder that contains **no** `.gguf` files. A warning dialog should appear, the app should start without models (no crash), and `prefs.json` should **not** be updated. + +### 10b — `MODELS_DIR` environment variable + +The env var takes priority over saved preferences. + +```bash +# macOS / Linux +MODELS_DIR=/path/to/your/models npm run electron:dev +``` + +**Expected**: +- The first-run dialog does **not** appear (models found via env var) +- The server uses the specified directory for model lookups +- The saved `prefs.json` (if any) is ignored in favour of the env var + +To verify the server received the correct path, check the log: + +```bash +# macOS +grep -i "MODELS_DIR\|models" ~/Library/Application\ Support/ACE-Step\ UI/logs/server.log | head -5 + +# Linux +grep -i "MODELS_DIR\|models" ~/.config/ACE-Step\ UI/logs/server.log | head -5 +``` + +--- + +## Quick checklist + +Copy this into a PR comment to report results: + +``` +### Electron pre-merge test results + +**Platform:** macOS arm64 / Linux x64 (circle one) + +- [ ] Step 1: Binary archive downloaded and extracted without errors +- [ ] Step 2: All binaries executable; all libggml dylibs / .so files present +- [ ] Step 3: Frontend and server builds succeed +- [ ] Step 4: `@electron/rebuild` succeeds for `better-sqlite3` +- [ ] Step 5: App starts — loading window shows "Checking models…" first, then "Starting server…"; UI opens; no library errors +- [ ] Step 6: `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH` points to `bin/` +- [ ] Step 7: All expected dylib / soname symlinks present after first launch +- [ ] Step 8: Generation smoke test completes and audio is playable +- [ ] Step 9 (macOS): Packaged `.dmg` installs and runs correctly +- [ ] Step 10a: Browse dialog — picking a valid folder persists path; invalid folder shows warning; preference survives relaunch +- [ ] Step 10b: `MODELS_DIR` env var overrides saved preference; dialog skipped when models found + +**Notes / failures:** + +``` diff --git a/electron/icons/icon.icns b/electron/icons/icon.icns new file mode 100644 index 0000000..6f7770d Binary files /dev/null and b/electron/icons/icon.icns differ diff --git a/electron/icons/icon.ico b/electron/icons/icon.ico new file mode 100644 index 0000000..0c0be5c Binary files /dev/null and b/electron/icons/icon.ico differ diff --git a/electron/icons/icon.png b/electron/icons/icon.png new file mode 100644 index 0000000..d08a161 Binary files /dev/null and b/electron/icons/icon.png differ diff --git a/electron/loading.html b/electron/loading.html new file mode 100644 index 0000000..b8627d7 --- /dev/null +++ b/electron/loading.html @@ -0,0 +1,134 @@ + + + + + + ACE-Step UI — Starting + + + +
+ +

ACE-Step UI

+

Local AI Music Generator

+ +

Initializing…

+ +
+
+
+

+ + + + diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000..546d32b --- /dev/null +++ b/electron/main.js @@ -0,0 +1,582 @@ +/** + * Electron main process — ACE-Step UI desktop shell + * + * Start-up sequence + * ───────────────── + * 1. ensureDirs() create user-space directories + * 2. setupLibraryPaths() set LD_LIBRARY_PATH (Linux) / DYLD_LIBRARY_PATH + * (macOS) to BIN_DIR so that ace-qwen3 / dit-vae + * child processes find their shared libraries. + * Linux ELFs have a hardcoded RUNPATH to the CI + * build tree; macOS dylibs use versioned install + * names — both need the env var override. + * 3. fixSonameLinks() Linux only: archive ships libggml.so / + * libggml-base.so (unversioned) but ELFs link + * against libggml.so.0 / libggml-base.so.0 — + * create the missing versioned symlinks. + * 4. fixMacosDylibLinks() macOS only: archive ships real versioned dylibs + * (libggml.0.9.7.dylib) plus a two-level symlink + * chain (.0.dylib → .0.9.7.dylib, .dylib → .0.dylib). + * Recreate any missing symlinks in case + * electron-builder did not preserve them. + * 5. show loading window file:// → electron/loading.html + * 6. checkFirstRun() resolve MODELS_DIR (env var → saved pref → default); + * if no .gguf files found, offer to download, browse + * to an existing models folder, or skip. + * 7. startServer() set all env-vars (including final MODELS_DIR), then + * dynamically import compiled server + * 8. waitForServer() poll until Express responds + * 9. open main window http://127.0.0.1:PORT + */ + +import { app, BrowserWindow, dialog, shell } from 'electron'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import fs from 'fs'; +import https from 'https'; +import http from 'http'; +import { URL as NodeURL } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ── Constants ──────────────────────────────────────────────────────────────── + +const SERVER_PORT = parseInt(process.env.PORT ?? '3001', 10); +const isPackaged = app.isPackaged; +const appRoot = app.getAppPath(); + +// User-writable directories (never inside asar) +const userDataPath = app.getPath('userData'); +const musicPath = app.getPath('music'); +const AUDIO_DIR = path.join(musicPath, 'ACEStep'); +const DATA_DIR = path.join(userDataPath, 'data'); +const LOGS_DIR = path.join(userDataPath, 'logs'); +const PREFS_PATH = path.join(userDataPath, 'prefs.json'); + +// Precompiled binaries land in extraResources → /bin/ +// In dev mode we fall back to /bin/ +const binExt = process.platform === 'win32' ? '.exe' : ''; +const BIN_DIR = isPackaged + ? path.join(process.resourcesPath, 'bin') + : path.join(appRoot, 'bin'); + +// Default model set — matches models.sh "Q8_0 essential" defaults +const HF_BASE = 'https://huggingface.co/Serveurperso/ACE-Step-1.5-GGUF/resolve/main'; +const DEFAULT_MODELS = [ + { filename: 'vae-BF16.gguf', label: 'VAE (BF16)' }, + { filename: 'Qwen3-Embedding-0.6B-Q8_0.gguf', label: 'Text Encoder Q8_0' }, + { filename: 'acestep-5Hz-lm-4B-Q8_0.gguf', label: 'Language Model 4B Q8_0' }, + { filename: 'acestep-v15-turbo-Q8_0.gguf', label: 'DiT Turbo Q8_0' }, +]; + +// ── Preferences ─────────────────────────────────────────────────────────────── + +/** Read the persisted JSON prefs file, returning {} on any error. */ +function loadPrefs () { + try { return JSON.parse(fs.readFileSync(PREFS_PATH, 'utf8')); } + catch (err) { + if (err.code !== 'ENOENT') console.error('[Electron] Failed to read prefs:', err.message); + return {}; + } +} + +/** Merge `patch` into the persisted prefs file (creates the file if absent). */ +function savePrefs (patch) { + const current = loadPrefs(); + try { fs.writeFileSync(PREFS_PATH, JSON.stringify({ ...current, ...patch }, null, 2)); } + catch (err) { console.error('[Electron] Failed to save prefs:', err.message); } +} + +// ── Models directory (resolved once at startup) ─────────────────────────────── +// +// Resolution priority: +// 1. MODELS_DIR environment variable — set before launching the app, e.g. +// MODELS_DIR=/Volumes/SSD/ai-models open ACE-Step\ UI.app +// 2. Saved user preference — path chosen via the "Browse" dialog on a +// previous launch, stored in /prefs.json +// 3. Default: /models (created automatically on first launch) +// +// `let` so that checkFirstRun() can update it when the user browses to an +// existing folder; startServer() then passes the final value to the server. +let MODELS_DIR = (() => { + if (process.env.MODELS_DIR) return path.resolve(process.env.MODELS_DIR); + const saved = loadPrefs().modelsDir; + if (saved) return saved; + return path.join(userDataPath, 'models'); +})(); + +// ── Window handles ──────────────────────────────────────────────────────────── + +let mainWindow = null; +let loadingWindow = null; +let serverLogStream = null; + +// ── Directory helpers ──────────────────────────────────────────────────────── + +function ensureDirs () { + for (const dir of [MODELS_DIR, AUDIO_DIR, DATA_DIR, LOGS_DIR]) { + try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {} + } +} + +// ── Library-path setup ─────────────────────────────────────────────────────── + +/** + * Prepend BIN_DIR to the platform's dynamic-library search path so that the + * ace-qwen3 / dit-vae child processes find their bundled shared libraries. + * + * Linux: The ELFs have a hardcoded RUNPATH pointing to the CI build tree + * (/home/runner/work/…) which never exists on user machines. + * LD_LIBRARY_PATH overrides the RUNPATH and is inherited by every child + * process spawned by the Express server. + * + * macOS: The release archive ships versioned dylibs (libggml.0.9.7.dylib, + * libggml-base.0.9.7.dylib, libggml-metal.0.9.7.dylib, etc.) alongside + * the binaries. The dyld linker checks DYLD_LIBRARY_PATH before the + * embedded @rpath or install-name path, so setting it to BIN_DIR ensures + * the bundled libraries are found regardless of what paths were baked in + * at compile time. DYLD_FALLBACK_LIBRARY_PATH is set as an additional + * safety net for transitive dylib-to-dylib dependencies. + */ +function setupLibraryPaths () { + if (!fs.existsSync(BIN_DIR)) return; + + if (process.platform === 'linux') { + const prev = process.env.LD_LIBRARY_PATH || ''; + process.env.LD_LIBRARY_PATH = prev ? `${BIN_DIR}:${prev}` : BIN_DIR; + } else if (process.platform === 'darwin') { + const prev = process.env.DYLD_LIBRARY_PATH || ''; + process.env.DYLD_LIBRARY_PATH = prev ? `${BIN_DIR}:${prev}` : BIN_DIR; + // Also set DYLD_FALLBACK_LIBRARY_PATH as an extra safety net + const prev2 = process.env.DYLD_FALLBACK_LIBRARY_PATH || ''; + process.env.DYLD_FALLBACK_LIBRARY_PATH = prev2 ? `${BIN_DIR}:${prev2}` : BIN_DIR; + } +} + +/** + * The Linux binary archive ships `libggml.so` / `libggml-base.so` but the + * ELFs link against the versioned sonames `libggml.so.0` / `libggml-base.so.0`. + * Create symlinks at runtime so the dynamic linker can resolve them. + * (The CI workflow also creates these symlinks before packaging, but we do it + * here too as a robust fallback — e.g. when running in dev mode.) + */ +function fixSonameLinks () { + if (process.platform !== 'linux') return; + const pairs = [ + ['libggml.so', 'libggml.so.0'], + ['libggml-base.so', 'libggml-base.so.0'], + ]; + for (const [real, soname] of pairs) { + const realPath = path.join(BIN_DIR, real); + const sonamePath = path.join(BIN_DIR, soname); + if (fs.existsSync(realPath) && !fs.existsSync(sonamePath)) { + try { fs.symlinkSync(real, sonamePath); } catch (_) {} + } + } +} + +/** + * The macOS binary archive ships real versioned dylibs (e.g. libggml.0.9.7.dylib) + * plus a two-level symlink chain: + * libggml.dylib → libggml.0.dylib → libggml.0.9.7.dylib + * + * electron-builder may not preserve symlinks when collecting extraResources. + * This function recreates any missing alias links so the dynamic linker can + * find the libraries regardless of which name the binary references. + * + * It is safe to call on every launch — existing symlinks are left untouched. + */ +function fixMacosDylibLinks () { + if (process.platform !== 'darwin') return; + let files; + try { files = fs.readdirSync(BIN_DIR); } catch (_) { return; } + + // Match versioned dylibs: libX.MAJOR.MINOR.PATCH.dylib + const verRe = /^(.+)\.(\d+)\.(\d+)\.(\d+)\.dylib$/; + for (const f of files) { + const match = f.match(verRe); + if (!match) continue; + const [, baseName, majorVersion] = match; + // e.g. baseName="libggml-metal", majorVersion="0" + const majorAlias = `${baseName}.${majorVersion}.dylib`; // libggml-metal.0.dylib + const simpleAlias = `${baseName}.dylib`; // libggml-metal.dylib + + const majorPath = path.join(BIN_DIR, majorAlias); + const simplePath = path.join(BIN_DIR, simpleAlias); + + // major alias → versioned real file + if (!fs.existsSync(majorPath)) try { fs.symlinkSync(f, majorPath); } catch (_) {} + // simple alias → major alias (two-step chain matches the macOS convention) + if (!fs.existsSync(simplePath)) try { fs.symlinkSync(majorAlias, simplePath); } catch (_) {} + } +} + +// ── Server startup ─────────────────────────────────────────────────────────── + +/** + * Set all environment variables that the Express server reads at import time, + * then dynamically import the compiled server entry point. + * + * dotenv inside the server only fills variables that are *not already set*, so + * our values here always take precedence over any .env file. + */ +async function startServer () { + process.env.PORT = String(SERVER_PORT); + process.env.NODE_ENV = 'production'; + process.env.MODELS_DIR = MODELS_DIR; + process.env.AUDIO_DIR = AUDIO_DIR; + process.env.DATABASE_PATH = path.join(DATA_DIR, 'acestep.db'); + if (!process.env.JWT_SECRET) { + process.env.JWT_SECRET = 'ace-step-ui-electron-local-secret'; + } + + const aceQwen3 = path.join(BIN_DIR, `ace-qwen3${binExt}`); + const ditVae = path.join(BIN_DIR, `dit-vae${binExt}`); + if (fs.existsSync(aceQwen3)) process.env.ACE_QWEN3_BIN = aceQwen3; + if (fs.existsSync(ditVae)) process.env.DIT_VAE_BIN = ditVae; + + // In packaged mode redirect server stdout/stderr to a persistent log file + if (isPackaged) { + const logPath = path.join(LOGS_DIR, 'server.log'); + serverLogStream = fs.createWriteStream(logPath, { flags: 'a' }); + for (const name of ['stdout', 'stderr']) { + const orig = process[name].write.bind(process[name]); + process[name].write = (chunk, enc, cb) => { + serverLogStream.write(chunk); + return orig(chunk, enc, cb); + }; + } + } + + const serverEntry = pathToFileURL( + path.join(appRoot, 'server', 'dist', 'index.js'), + ).href; + + try { + await import(serverEntry); + } catch (err) { + console.error('[Electron] Failed to start embedded server:', err); + } +} + +/** Poll until the Express server accepts connections, then resolve. */ +function waitForServer (maxTries = 60, intervalMs = 500) { + return new Promise((resolve) => { + let tries = 0; + const attempt = () => { + const req = http.get(`http://127.0.0.1:${SERVER_PORT}/`, (res) => { + res.resume(); + resolve(); + }); + req.setTimeout(1000, () => req.destroy()); + req.on('error', () => { + if (++tries < maxTries) setTimeout(attempt, intervalMs); + else resolve(); + }); + }; + attempt(); + }); +} + +// ── Loading window ──────────────────────────────────────────────────────────── + +function createLoadingWindow () { + loadingWindow = new BrowserWindow({ + width: 480, + height: 320, + resizable: false, + frame: false, + transparent: false, + alwaysOnTop: true, + title: 'ACE-Step UI — Starting', + icon: path.join(__dirname, 'icons', 'icon.png'), + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + loadingWindow.loadFile(path.join(__dirname, 'loading.html')); + loadingWindow.on('closed', () => { loadingWindow = null; }); +} + +function sendStatus (msg, pct = -1, label = '') { + if (loadingWindow && !loadingWindow.isDestroyed()) { + loadingWindow.webContents.send('setup:status', { msg, pct, label }); + } +} + +// ── Model download ──────────────────────────────────────────────────────────── + +function hasModels () { + try { + return fs.readdirSync(MODELS_DIR).some(f => f.endsWith('.gguf') && !f.endsWith('.part')); + } catch { return false; } +} + +/** + * Download a single file from `url` to `destPath`, following HTTP redirects. + * Calls `progressCb(downloaded, total)` as data arrives. + * Writes to `destPath + '.part'` and renames atomically on success. + */ +function downloadFile (url, destPath, progressCb, maxRedirects = 15) { + return new Promise((resolve, reject) => { + if (maxRedirects <= 0) return reject(new Error('Too many redirects')); + + const parsed = new NodeURL(url); + const getter = parsed.protocol === 'https:' ? https : http; + + const req = getter.get(url, { headers: { 'User-Agent': 'ACE-Step-UI-Electron/1.0' } }, (res) => { + // Follow redirects (HuggingFace → CDN is a common pattern) + if (res.statusCode >= 301 && res.statusCode <= 308 && res.headers.location) { + res.resume(); + const next = new NodeURL(res.headers.location, url).href; + return downloadFile(next, destPath, progressCb, maxRedirects - 1) + .then(resolve, reject); + } + + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); + } + + const total = parseInt(res.headers['content-length'] ?? '0', 10); + let downloaded = 0; + const tmpPath = `${destPath}.part`; + const out = fs.createWriteStream(tmpPath); + + res.on('data', (chunk) => { + downloaded += chunk.length; + if (total > 0 && progressCb) progressCb(downloaded, total); + }); + + res.pipe(out); + + out.on('finish', () => + out.close(() => + fs.rename(tmpPath, destPath, (err) => { + if (err) { try { fs.unlinkSync(tmpPath); } catch (_) {} reject(err); } + else resolve(); + }) + ) + ); + out.on('error', (err) => { + try { fs.unlinkSync(tmpPath); } catch (_) {} + reject(err); + }); + }); + + req.on('error', reject); + }); +} + +function fmtBytes (bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; + return `${(bytes / 1024 ** 3).toFixed(2)} GB`; +} + +/** + * Download all DEFAULT_MODELS into MODELS_DIR, skipping files that are + * already present and complete (> 1 MB). + */ +async function downloadModels () { + fs.mkdirSync(MODELS_DIR, { recursive: true }); + + for (let i = 0; i < DEFAULT_MODELS.length; i++) { + const { filename, label } = DEFAULT_MODELS[i]; + const destPath = path.join(MODELS_DIR, filename); + + // Skip if already downloaded (> 1 MB = not a truncated/partial file) + try { + if (fs.statSync(destPath).size > 1_048_576) { + sendStatus( + `Skipping ${label} (already downloaded)`, + Math.round(((i + 1) / DEFAULT_MODELS.length) * 100), + ); + continue; + } + } catch (_) { /* file doesn't exist */ } + + sendStatus(`Downloading ${label}… (${i + 1}/${DEFAULT_MODELS.length})`, -1, ''); + + try { + await downloadFile( + `${HF_BASE}/${filename}`, + destPath, + (dl, total) => { + const pct = Math.round((dl / total) * 100); + const fileProgress = Math.round( + ((i + (dl / total)) / DEFAULT_MODELS.length) * 100, + ); + sendStatus( + `Downloading ${label}… (${i + 1}/${DEFAULT_MODELS.length})`, + fileProgress, + `${fmtBytes(dl)} / ${fmtBytes(total)} (${pct}%)`, + ); + }, + ); + } catch (err) { + console.error(`[Electron] Failed to download ${filename}:`, err.message); + sendStatus(`Error downloading ${label}: ${err.message}`, -1, ''); + // Continue with remaining models rather than aborting the whole setup + } + } +} + +// ── First-run check ─────────────────────────────────────────────────────────── + +async function checkFirstRun () { + if (hasModels()) return; // all good + + sendStatus('No GGUF models found in your models directory.'); + + const { response } = await dialog.showMessageBox({ + type: 'question', + buttons: [ + 'Download now (~8 GB)', + 'Browse for existing models…', + "Skip — I'll add models manually", + ], + defaultId: 0, + cancelId: 2, + title: 'ACE-Step — First Run Setup', + message: 'ACE-Step models not found', + detail: + `No .gguf model files were found in:\n${MODELS_DIR}\n\n` + + 'Choose an option:\n' + + ' • Download — fetch the default Q8_0 model set (~8 GB) from HuggingFace\n' + + ' • Browse — point to a folder where you already have ACE-Step .gguf files\n' + + ' • Skip — start without models and add them manually later\n\n' + + 'A browsed folder path is saved and reused on every subsequent launch.\n' + + 'You can also set MODELS_DIR in your environment before launching the app.', + }); + + if (response === 0) { + // ── Download ──────────────────────────────────────────────────────────── + sendStatus('Starting model downloads…', 0, ''); + await downloadModels(); + sendStatus('Models ready!', 100, ''); + await new Promise(r => setTimeout(r, 800)); // brief pause so user sees "ready" + + } else if (response === 1) { + // ── Browse for existing models ────────────────────────────────────────── + const { canceled, filePaths } = await dialog.showOpenDialog({ + title: 'Select your ACE-Step models folder', + defaultPath: app.getPath('home'), + properties: ['openDirectory'], + buttonLabel: 'Use this folder', + }); + + if (!canceled && filePaths.length > 0) { + const chosen = filePaths[0]; + let hasGguf = false; + try { + hasGguf = fs.readdirSync(chosen).some( + f => f.endsWith('.gguf') && !f.endsWith('.part'), + ); + } catch (_) {} + + if (hasGguf) { + MODELS_DIR = chosen; + savePrefs({ modelsDir: chosen }); + sendStatus(`Using models from: ${path.basename(chosen)}`); + } else { + await dialog.showMessageBox({ + type: 'warning', + buttons: ['OK'], + title: 'No models found', + message: 'No .gguf files found in the selected folder', + detail: + `${chosen}\n\n` + + 'The app will start without models loaded. ' + + 'You can set MODELS_DIR in your environment and relaunch, ' + + 'or place .gguf files in the folder shown above.', + }); + } + } + // Canceled or no valid folder → fall through and start without models + } + // response === 2 → skip, start without models +} + +// ── Main window ─────────────────────────────────────────────────────────────── + +function createWindow () { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 800, + minHeight: 600, + title: 'ACE-Step UI', + icon: path.join(__dirname, 'icons', 'icon.png'), + show: false, // reveal after content loads to avoid flash + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + + mainWindow.loadURL(`http://127.0.0.1:${SERVER_PORT}`); + + mainWindow.once('ready-to-show', () => { + if (loadingWindow && !loadingWindow.isDestroyed()) { + loadingWindow.close(); + } + mainWindow.show(); + }); + + // Open links in the default system browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http')) shell.openExternal(url); + return { action: 'deny' }; + }); + + mainWindow.on('closed', () => { mainWindow = null; }); +} + +// ── App lifecycle ───────────────────────────────────────────────────────────── + +app.whenReady().then(async () => { + ensureDirs(); + setupLibraryPaths(); + fixSonameLinks(); + fixMacosDylibLinks(); + + createLoadingWindow(); + + sendStatus('Checking models…'); + await checkFirstRun(); + + sendStatus('Starting server…'); + await startServer(); + + sendStatus('Waiting for server…'); + await waitForServer(); + + sendStatus('Opening app…', 100, ''); + createWindow(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); +}); + +// Quit the app (and the in-process Express server) whenever the last window +// is closed, including on macOS where the default behavior would be to keep +// the app running in the Dock. +app.on('window-all-closed', () => { + app.quit(); +}); + +app.on('before-quit', () => { + if (serverLogStream) { + serverLogStream.end(); + serverLogStream = null; + } +}); diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..6b1e301 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,39 @@ +/** + * Electron preload script — ACE-Step UI + * + * Shared by both the loading/setup window (electron/loading.html) and the + * main app window (http://localhost:PORT). + * + * Exposes two namespaces via contextBridge: + * • setupAPI — used by loading.html to receive setup-progress events + * • electronAPI — read-only platform info available to the main React app + * + * The React app communicates with the backend through HTTP on localhost, so + * no additional IPC channels are needed for the main window. + */ + +import { contextBridge, ipcRenderer } from 'electron'; + +// ── Setup / loading window API ──────────────────────────────────────────────── +// Receives status events sent from the main process during startup and +// first-run model downloads. +contextBridge.exposeInMainWorld('setupAPI', { + /** + * Register a callback that receives `{ msg, pct, label }` objects + * whenever the main process calls `win.webContents.send('setup:status', …)`. + */ + onStatus: (callback) => { + ipcRenderer.on('setup:status', (_event, data) => callback(data)); + }, +}); + +// ── Main app API ────────────────────────────────────────────────────────────── +contextBridge.exposeInMainWorld('electronAPI', { + /** Current OS platform string, e.g. 'darwin', 'linux', 'win32' */ + platform: process.platform, + /** Electron and Node.js version strings for diagnostics */ + versions: { + electron: process.versions.electron, + node: process.versions.node, + }, +}); diff --git a/package.json b/package.json index 62db910..6158b64 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,17 @@ "private": true, "version": "1.0.0", "type": "module", + "main": "electron/main.js", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", - "start": "cd server && npx tsx src/index.ts & npm run dev" + "start": "cd server && npx tsx src/index.ts & npm run dev", + "electron:dev": "electron .", + "electron:build": "electron-builder", + "electron:build:mac": "electron-builder --mac", + "electron:build:linux": "electron-builder --linux", + "electron:build:win": "electron-builder --win" }, "dependencies": { "@ffmpeg/ffmpeg": "^0.12.15", @@ -18,12 +24,69 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@electron/rebuild": "^3.6.0", "@types/node": "^22.14.0", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.27", + "electron": "^33.0.0", + "electron-builder": "^25.1.8", "postcss": "^8.5.8", "tailwindcss": "^3.4.19", "typescript": "~5.8.2", "vite": "^6.2.0" + }, + "build": { + "appId": "io.audiohacking.acestep-cpp-ui", + "productName": "ACE-Step UI", + "npmRebuild": false, + "icon": "electron/icons/icon", + "directories": { + "output": "release" + }, + "files": [ + "electron/**/*", + "dist/**/*", + "server/dist/**/*", + { + "from": "server/node_modules", + "to": "server/node_modules", + "filter": ["**/*"] + }, + "server/package.json" + ], + "asarUnpack": [ + "**/better-sqlite3/**" + ], + "extraResources": [ + { + "from": "bin", + "to": "bin", + "filter": ["**/*"] + } + ], + "mac": { + "target": [ + { "target": "dmg", "arch": ["arm64"] } + ], + "category": "public.app-category.music", + "icon": "electron/icons/icon.icns" + }, + "linux": { + "target": ["AppImage", "snap"], + "category": "Audio", + "icon": "electron/icons/icon.png" + }, + "snap": { + "summary": "ACE-Step UI — AI music generation desktop app", + "description": "Native desktop application for ACE-Step AI music generation with embedded server and precompiled binaries. Supports text-to-music, cover, repaint and LEGO modes.", + "grade": "stable", + "confinement": "classic" + }, + "win": { + "target": [ + { "target": "nsis", "arch": ["x64"] } + ], + "icon": "electron/icons/icon.ico" + } } } diff --git a/server/src/index.ts b/server/src/index.ts index e336f78..659a067 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -57,6 +57,14 @@ app.use(cors({ origin: (origin, callback) => { // Allow requests with no origin (mobile apps, curl, etc.) if (!origin) return callback(null, true); + // Always allow loopback origins on the server's own port — this covers the + // Electron desktop shell, which imports and starts the Express server in the + // same process and loads http://127.0.0.1:PORT in a BrowserWindow. + const loopbackOrigins = new Set([ + `http://localhost:${config.port}`, + `http://127.0.0.1:${config.port}`, + ]); + if (loopbackOrigins.has(origin)) return callback(null, true); // Allow localhost and 127.0.0.1 on any port in development if (config.nodeEnv === 'development') { if (origin.includes('localhost') || origin.includes('127.0.0.1')) {