diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e202d35..02504a7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,7 @@ jobs: include: - platform: windows-latest - platform: ubuntu-22.04 + - platform: macos-latest runs-on: ${{ matrix.platform }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96c2d623..7622eae9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ jobs: include: - platform: windows-latest - platform: ubuntu-22.04 + - platform: macos-latest runs-on: ${{ matrix.platform }} @@ -40,8 +41,37 @@ jobs: - name: Install dependencies run: npm ci + - name: Import Apple code-signing certificate + if: runner.os == 'macOS' + env: + APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + APPLE_CERTIFICATE_P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }} + run: | + if [ -z "$APPLE_CERTIFICATE_P12_BASE64" ] || [ -z "$APPLE_CERTIFICATE_P12_PASSWORD" ]; then + echo "Missing Apple certificate secrets. Add APPLE_CERTIFICATE_P12_BASE64 and APPLE_CERTIFICATE_P12_PASSWORD." + exit 1 + fi + + KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" + CERT_PATH="$RUNNER_TEMP/certificate.p12" + + echo "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$CERT_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + security import "$CERT_PATH" -k build.keychain -P "$APPLE_CERTIFICATE_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain + security find-identity -v -p codesigning build.keychain + - name: Build Electron app run: npm run build + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MAC_CODESIGN_IDENTITY: ${{ secrets.MAC_CODESIGN_IDENTITY }} - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -67,5 +97,6 @@ jobs: files: | artifacts/**/*.exe artifacts/**/*.AppImage + artifacts/**/*.dmg prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} generate_release_notes: true diff --git a/.prettierignore b/.prettierignore index a74f682d..2cbee364 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ server-components out .vite build/installer.nsh +build/entitlements.mac.plist diff --git a/app-icon.icns b/app-icon.icns new file mode 100644 index 00000000..c7de021b Binary files /dev/null and b/app-icon.icns differ diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 00000000..9a279dc8 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/electron/ipc/settings.ts b/electron/ipc/settings.ts index 78603411..3e822149 100644 --- a/electron/ipc/settings.ts +++ b/electron/ipc/settings.ts @@ -11,6 +11,23 @@ const log = getLogger('electron.settings') const SETTINGS_FILENAME = 'settings.json' const LEGACY_CONFIG_FILENAME = 'config.json' +// Apple Silicon has no CUDA, so the legacy `world_engine` backend can't even +// import there — the server advertises `quark` only (see manager.py's +// `IS_DARWIN_ARM64` gate in `supported_capabilities`). The client setting +// defaults to `world_engine` for every platform, so without this a Mac would +// try to launch the engine on a backend that can't load (and the +// capability-based clamp only kicks in *after* a server reports `/health`, +// which is too late for a standalone launch). Pin the setting to `quark` here +// so it propagates to the server via the InitRequest. Mirrors the server gate. +const IS_APPLE_SILICON = process.platform === 'darwin' && process.arch === 'arm64' + +function normalizeForPlatform(settings: Settings): { settings: Settings; changed: boolean } { + if (IS_APPLE_SILICON && settings.engine_backend !== 'quark') { + return { settings: { ...settings, engine_backend: 'quark' }, changed: true } + } + return { settings, changed: false } +} + function getSettingsPath(): string { const configDir = getConfigDir() if (!fs.existsSync(configDir)) { @@ -161,10 +178,11 @@ function loadSettings(settingsPath: string): { settings: Settings; dirty: boolea export function readSettingsSync(): Settings { const settingsPath = getSettingsPath() const { settings, dirty } = loadSettings(settingsPath) - if (dirty) { - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)) + const { settings: normalized, changed } = normalizeForPlatform(settings) + if (dirty || changed) { + fs.writeFileSync(settingsPath, JSON.stringify(normalized, null, 2)) } - return settings + return normalized } /** Env vars injected into any uv / python subprocess when offline mode is on. @@ -195,12 +213,12 @@ export function registerSettingsIpc(): void { }) ipcMain.handle('read-default-settings', () => { - return settingsSchema.parse({}) + return normalizeForPlatform(settingsSchema.parse({})).settings }) ipcMain.handle('write-settings', (_event, settings: Settings) => { const settingsPath = getSettingsPath() - const validated = settingsSchema.parse(settings) + const validated = normalizeForPlatform(settingsSchema.parse(settings)).settings fs.writeFileSync(settingsPath, JSON.stringify(validated, null, 2)) }) diff --git a/forge.config.ts b/forge.config.ts index c337829c..98fa7f6f 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -9,10 +9,29 @@ import { dirname, resolve } from 'node:path' const __dirname = dirname(fileURLToPath(import.meta.url)) +const shouldSignMac = + process.platform === 'darwin' && (Boolean(process.env.CSC_LINK) || Boolean(process.env.MAC_CODESIGN_IDENTITY)) + +const shouldNotarizeMac = + shouldSignMac && + Boolean(process.env.APPLE_ID) && + Boolean(process.env.APPLE_APP_SPECIFIC_PASSWORD) && + Boolean(process.env.APPLE_TEAM_ID) + +const macNotarizeCredentials = shouldNotarizeMac + ? { + appleId: process.env.APPLE_ID as string, + appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD as string, + teamId: process.env.APPLE_TEAM_ID as string + } + : undefined + const config: ForgeConfig = { packagerConfig: { asar: true, executableName: 'biome', + appBundleId: 'ai.overworld.biome', + appCategoryType: 'public.app-category.games', icon: './app-icon', appCopyright: 'Copyright © 2026 Overworld', extraResource: [ @@ -23,7 +42,17 @@ const config: ForgeConfig = { './assets/9SALERNO.TTF', './app-icon.ico', './app-icon.png' - ] + ], + osxSign: shouldSignMac + ? { + identity: process.env.MAC_CODESIGN_IDENTITY || undefined, + optionsForFile: () => ({ + hardenedRuntime: true, + entitlements: 'build/entitlements.mac.plist' + }) + } + : undefined, + osxNotarize: macNotarizeCredentials }, makers: [ new MakerNSIS({ diff --git a/server-components/engine/manager.py b/server-components/engine/manager.py index 3c6bb71d..534db491 100755 --- a/server-components/engine/manager.py +++ b/server-components/engine/manager.py @@ -572,6 +572,14 @@ async def load_engine( backend_quant = _QUARK_QUANT_MAP[requested_quant] except KeyError as e: raise QuarkUnsupportedQuantError(requested_quant) from e + # Apple Silicon has no native fp8, so quark forces all-bf16 + # regardless of what we pass. But quark reads `quant=None` as + # "default → fp8" and emits a RuntimeWarning when it rewrites + # that to bf16. We only advertise `Quant.NONE` on Metal anyway, + # so make the bf16 intent explicit to keep the engine logs + # clean (quark returns `"bf16"` unchanged, no warning). + if IS_DARWIN_ARM64 and backend_quant is None: + backend_quant = "bf16" else: backend_quant = requested_quant