diff --git a/.github/RELEASING.md b/.github/RELEASING.md new file mode 100644 index 0000000000..292a159a12 --- /dev/null +++ b/.github/RELEASING.md @@ -0,0 +1,38 @@ +# MobileCode releases + +MobileCode publishes unsigned Apple-silicon macOS builds from `release-macos.yml`. The workflow creates a GitHub release with the DMG, ZIP, and `latest-mac.yml`, and also retains those files as workflow-run artifacts. + +## One-time GitHub setup + +1. In **Settings → Actions → General**, enable GitHub Actions and allow actions created by GitHub and verified creators. +2. In the **Actions** tab, disable inherited OpenCode workflows that MobileCode does not use. Leave **Release macOS** enabled. Workflow enabled/disabled state is stored by GitHub, so this avoids changing upstream workflow files and keeps upstream merges straightforward. +3. In **Settings → Actions → General → Workflow permissions**, select **Read and write permissions**. The release workflow also declares its narrower `contents: write` permission. + +No Apple secrets are required while builds are unsigned. macOS auto-update is intentionally disabled for unsigned builds because Squirrel.Mac requires code signing. Users download each release manually and must approve the app on first launch. + +## Create a release + +Open **Actions → Release macOS → Run workflow**, choose `dev`, and choose whether to publish immediately. The workflow reads the version from `packages/opencode/package.json` and creates the matching tag. For example, OpenCode version `1.18.29` produces `v1.18.29`. Reruns accept that tag only when it still points to the selected commit. + +Pushing a `v*` tag is also supported and creates a draft release for review, but the tag version must match `packages/opencode/package.json`. + +## Sync from OpenCode + +Keep the `upstream` remote pointed at `https://github.com/anomalyco/opencode.git`, then merge upstream into MobileCode's `dev` branch: + +```sh +git fetch upstream +git checkout dev +git merge upstream/dev +``` + +Keep upstream's internal `@opencode-ai/*` workspace package names, `OPENCODE_*` environment variables, server protocol, and CLI terminology. They are implementation contracts, not installed-app identity, and retaining them minimizes merge conflicts. MobileCode-specific identity is limited to packaging metadata, application data directories, URL protocol, assets, and release configuration. + +After resolving any branded-file conflicts, verify from `packages/desktop`: + +```sh +bun test electron-builder.config.test.ts +bun typecheck +``` + +For a local unsigned release build, run `bun run build:macos` from the repository root. diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000000..131fa15003 --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,131 @@ +name: Release macOS + +# Builds the MobileCode desktop app for Apple silicon and attaches it to a GitHub release. +# +# Two ways to run it: +# - Push a tag matching packages/opencode/package.json. +# - Run it manually. The workflow reads that version and creates the matching tag. +# +# The release is created as a draft so it can be reviewed, then published from the GitHub UI +# (or pass publish=true). The zip and latest-mac.yml are what the in-app updater reads, so keep +# the asset names electron-builder chooses. +# +# Builds are intentionally unsigned for now. Users must right-click the app and choose Open +# the first time they launch it. + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + publish: + description: "Publish the release immediately instead of leaving it as a draft" + required: false + type: boolean + default: false + +permissions: + contents: write + +concurrency: release-macos-${{ github.ref }} + +jobs: + build: + if: github.repository == 'hsandhu/mobilecode' + runs-on: macos-15 + timeout-minutes: 90 + env: + OPENCODE_CHANNEL: prod + MOBILECODE_UPDATER_ENABLED: "false" + CSC_IDENTITY_AUTO_DISCOVERY: "false" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Resolve version + id: version + run: | + set -euo pipefail + PACKAGE_VERSION="$(node -p "require('./packages/opencode/package.json').version")" + if [ "${{ github.event_name }}" = "push" ]; then + VERSION="${GITHUB_REF_NAME#v}" + else + VERSION="$PACKAGE_VERSION" + fi + if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Version must be a stable SemVer such as 1.2.3, got '$VERSION'" + exit 1 + fi + if [ "$VERSION" != "$PACKAGE_VERSION" ]; then + echo "Release version $VERSION must match OpenCode version $PACKAGE_VERSION" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Create or verify tag + if: github.event_name == 'workflow_dispatch' + run: | + set -euo pipefail + TAG="${{ steps.version.outputs.tag }}" + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + TAG_COMMIT="$(git rev-list -n 1 "$TAG")" + if [ "$TAG_COMMIT" != "$GITHUB_SHA" ]; then + echo "Tag $TAG already points to $TAG_COMMIT, not the selected commit $GITHUB_SHA" + exit 1 + fi + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "MobileCode $TAG" + git push origin "$TAG" + fi + git checkout --detach "$TAG" + + - uses: ./.github/actions/setup-bun + + - name: Test desktop + working-directory: packages/desktop + run: bun test + + - name: Typecheck desktop + working-directory: packages/desktop + run: bun typecheck + + - name: Prepare + working-directory: packages/desktop + run: bun ./scripts/prepare.ts + env: + OPENCODE_VERSION: ${{ steps.version.outputs.version }} + + - name: Build + working-directory: packages/desktop + run: bun run build + env: + NODE_OPTIONS: --max-old-space-size=4096 + OPENCODE_VERSION: ${{ steps.version.outputs.version }} + + - name: Package and attach to release + working-directory: packages/desktop + run: bun x electron-builder --mac --arm64 --publish always --config electron-builder.config.ts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Keep the artifacts on the run too + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: mobilecode-desktop-mac-arm64 + path: | + packages/desktop/dist/*.dmg + packages/desktop/dist/*.zip + packages/desktop/dist/latest-mac.yml + if-no-files-found: error + + - name: Publish release + if: github.event_name == 'workflow_dispatch' && inputs.publish + run: gh release edit "${{ steps.version.outputs.tag }}" --draft=false + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 6dd9a202ad..9e17b6f4ed 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -1,6 +1,6 @@ -# OpenCode Desktop +# MobileCode Desktop -The OpenCode Desktop app, built with Electron. +The MobileCode desktop app, built with Electron and kept close to the upstream OpenCode desktop package. ## Development diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 3fb1adb6c1..61e8331a44 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -1,14 +1,23 @@ import { expect, test } from "bun:test" import type { Configuration } from "electron-builder" -const legacyDesktopEntry = "resources/linux/opencode-desktop.desktop" - const channels = [ - { channel: "dev", appId: "ai.opencode.desktop.dev" }, - { channel: "beta", appId: "ai.opencode.desktop.beta" }, - { channel: "prod", appId: "ai.opencode.desktop" }, + { channel: "dev", appId: "dev.mobilecode.desktop.dev", productName: "MobileCode Dev", protocolName: "MobileCode" }, + { + channel: "beta", + appId: "dev.mobilecode.desktop.beta", + productName: "MobileCode Beta", + protocolName: "MobileCode Beta", + }, + { channel: "prod", appId: "dev.mobilecode.desktop", productName: "MobileCode", protocolName: "MobileCode" }, ] as const +test("tracks the OpenCode version", async () => { + const desktop = await Bun.file("package.json").json() + const opencode = await Bun.file("../opencode/package.json").json() + expect(desktop.version).toBe(opencode.version) +}) + for (const channel of channels) { test(`uses one Linux desktop identity for ${channel.channel}`, async () => { const previous = process.env.OPENCODE_CHANNEL @@ -21,40 +30,32 @@ for (const channel of channels) { else process.env.OPENCODE_CHANNEL = previous expect(config.appId).toBe(channel.appId) + expect(config.productName).toBe(channel.productName) + expect(config.artifactName).toBe("mobilecode-desktop-${os}-${arch}.${ext}") + expect(config.extraMetadata?.name).toBe("mobilecode-desktop") expect(config.extraMetadata?.desktopName).toBe(`${channel.appId}.desktop`) expect(config.linux?.executableName).toBe(channel.appId) expect(config.linux?.desktop?.entry?.StartupWMClass).toBe(channel.appId) + expect(config.protocols).toEqual({ + name: channel.protocolName, + schemes: ["mobilecode"], + }) expect(config.deb?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`)) expect(config.rpm?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`)) }) } -test("keeps a hidden prod launcher for old Linux pins", async () => { +test("publishes production updates only from the MobileCode repository", async () => { const previous = process.env.OPENCODE_CHANNEL process.env.OPENCODE_CHANNEL = "prod" - const module = await import("./electron-builder.config.ts?compat=prod") + const module = await import("./electron-builder.config.ts?publish=prod") const config = module.default as Configuration if (previous === undefined) delete process.env.OPENCODE_CHANNEL else process.env.OPENCODE_CHANNEL = previous - expect( - config.deb?.fpm?.some((entry) => - entry.endsWith("opencode-desktop.desktop=/usr/share/applications/opencode-desktop.desktop"), - ), - ).toBe(true) - expect( - config.rpm?.fpm?.some((entry) => - entry.endsWith("opencode-desktop.desktop=/usr/share/applications/opencode-desktop.desktop"), - ), - ).toBe(true) - - const desktop = await Bun.file(legacyDesktopEntry).text() - expect(desktop).toContain("Exec=/opt/OpenCode/ai.opencode.desktop %U") - expect(desktop).toContain("Icon=ai.opencode.desktop") - expect(desktop).toContain("StartupWMClass=ai.opencode.desktop") - expect(desktop).toContain("NoDisplay=true") + expect(config.publish).toEqual({ provider: "github", owner: "hsandhu", repo: "mobilecode", channel: "latest" }) }) test("bundles the CLI outside the dev app archive", async () => { @@ -65,11 +66,11 @@ test("bundles the CLI outside the dev app archive", async () => { if (previous === undefined) delete process.env.OPENCODE_CHANNEL else process.env.OPENCODE_CHANNEL = previous - expect(config.files).toContain("!resources/opencode-cli*") + expect(config.files).toContain("!resources/mobilecode-cli*") expect(config.extraResources).toContainEqual({ from: "resources/", to: "", - filter: ["opencode-cli*"], + filter: ["mobilecode-cli*"], }) }) @@ -85,7 +86,21 @@ for (const channel of ["beta", "prod"] as const) { expect(config.extraResources).not.toContainEqual({ from: "resources/", to: "", - filter: ["opencode-cli*"], + filter: ["mobilecode-cli*"], }) }) } + +test("supports unsigned macOS packages", async () => { + const previous = process.env.CSC_IDENTITY_AUTO_DISCOVERY + process.env.CSC_IDENTITY_AUTO_DISCOVERY = "false" + + const module = await import("./electron-builder.config.ts?unsigned=macos") + const config = module.default as Configuration + + if (previous === undefined) delete process.env.CSC_IDENTITY_AUTO_DISCOVERY + else process.env.CSC_IDENTITY_AUTO_DISCOVERY = previous + + expect(config.mac?.notarize).toBe(false) + expect(config.dmg?.sign).toBe(false) +}) diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 25bd389bde..e6673ee713 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url" import { promisify } from "node:util" import type { Configuration } from "electron-builder" +import { DESKTOP_APP_IDS, DESKTOP_APP_NAMES } from "./product" const execFileAsync = promisify(execFile) const packageDir = path.dirname(fileURLToPath(import.meta.url)) @@ -24,18 +25,17 @@ async function signWindows(configuration: { path: string }) { ) } +// A build without an Apple Developer identity still has to produce an app: skip signing and +// notarization when the workflow (or a local run) turns identity discovery off. +const signing = process.env.CSC_IDENTITY_AUTO_DISCOVERY !== "false" +const notarize = signing && Boolean(process.env.APPLE_API_KEY) + const channel = (() => { const raw = process.env.OPENCODE_CHANNEL if (raw === "dev" || raw === "beta" || raw === "prod") return raw return "dev" })() -const APP_IDS = { - dev: "dev.mobilecode.desktop.dev", - beta: "dev.mobilecode.desktop.beta", - prod: "dev.mobilecode.desktop", -} as const - const getBase = (appId: string): Configuration => ({ artifactName: "mobilecode-desktop-${os}-${arch}.${ext}", directories: { @@ -48,6 +48,9 @@ const getBase = (appId: string): Configuration => ({ // https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html // https://www.electron.build/docs/linux/ extraMetadata: { + // electron-updater derives its cache directory from the package name. Keep downloads + // isolated from OpenCode even though workspace package names stay upstream-compatible. + name: "mobilecode-desktop", desktopName: `${appId}.desktop`, }, files: ["out/**/*", "resources/**/*", "!resources/mobilecode-cli*"], @@ -74,11 +77,11 @@ const getBase = (appId: string): Configuration => ({ gatekeeperAssess: false, entitlements: "resources/entitlements.plist", entitlementsInherit: "resources/entitlements.plist", - notarize: true, + notarize, target: ["dmg", "zip"], }, dmg: { - sign: true, + sign: signing, }, protocols: { name: "MobileCode", @@ -114,7 +117,7 @@ const getBase = (appId: string): Configuration => ({ }) function getConfig() { - const appId = APP_IDS[channel] + const appId = DESKTOP_APP_IDS[channel] const base = getBase(appId) switch (channel) { @@ -122,7 +125,7 @@ function getConfig() { return { ...base, appId, - productName: "MobileCode Dev", + productName: DESKTOP_APP_NAMES.dev, deb: { fpm: [metainfoFpm(appId)] }, rpm: { packageName: "mobilecode-dev", fpm: [metainfoFpm(appId)] }, } @@ -131,7 +134,7 @@ function getConfig() { return { ...base, appId, - productName: "MobileCode Beta", + productName: DESKTOP_APP_NAMES.beta, protocols: { name: "MobileCode Beta", schemes: ["mobilecode"] }, publish: { provider: "github", owner: "hsandhu", repo: "mobilecode-beta", channel: "latest" }, deb: { fpm: [metainfoFpm(appId)] }, @@ -142,7 +145,7 @@ function getConfig() { return { ...base, appId, - productName: "MobileCode", + productName: DESKTOP_APP_NAMES.prod, protocols: { name: "MobileCode", schemes: ["mobilecode"] }, publish: { provider: "github", owner: "hsandhu", repo: "mobilecode", channel: "latest" }, deb: { fpm: [metainfoFpm(appId)] }, diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 5d25be209e..b1c450be2f 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -35,6 +35,9 @@ export default defineConfig({ main: { define: { "import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel), + "import.meta.env.MOBILECODE_UPDATER_ENABLED": JSON.stringify( + process.env.MOBILECODE_UPDATER_ENABLED === "true" ? "true" : "false", + ), }, build: { rollupOptions: { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 972d113790..db31d795a7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -2,12 +2,12 @@ "name": "@opencode-ai/desktop", "private": true, "version": "1.18.29", + "description": "MobileCode desktop app with embedded iOS and Android previews", "type": "module", "license": "MIT", "homepage": "https://github.com/hsandhu/mobilecode", "author": { - "name": "MobileCode", - "email": "hello@opencode.ai" + "name": "MobileCode contributors" }, "scripts": { "typecheck": "tsgo -b", diff --git a/packages/desktop/product.ts b/packages/desktop/product.ts new file mode 100644 index 0000000000..572c72b237 --- /dev/null +++ b/packages/desktop/product.ts @@ -0,0 +1,11 @@ +export const DESKTOP_APP_IDS = { + dev: "dev.mobilecode.desktop.dev", + beta: "dev.mobilecode.desktop.beta", + prod: "dev.mobilecode.desktop", +} as const + +export const DESKTOP_APP_NAMES = { + dev: "MobileCode Dev", + beta: "MobileCode Beta", + prod: "MobileCode", +} as const diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts index 4630854a24..8263babad6 100644 --- a/packages/desktop/src/main/background-cli.ts +++ b/packages/desktop/src/main/background-cli.ts @@ -8,15 +8,14 @@ import { app } from "electron" const execFileAsync = promisify(execFile) const root = dirname(fileURLToPath(import.meta.url)) -const stateHome = process.env.XDG_STATE_HOME -const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"] type Logger = { log(message: string, meta?: Record): void error(message: string, meta?: Record): void } -export async function startBackgroundCli(logger: Logger, shellStateHome?: string) { +export async function startBackgroundCli(logger: Logger) { + const stateHome = join(app.getPath("userData"), "state") const bundled = app.isPackaged ? join(process.resourcesPath, executableName()) : join(root, "../../resources", executableName()) @@ -24,29 +23,19 @@ export async function startBackgroundCli(logger: Logger, shellStateHome?: string const version = await run(bundled, ["--version"], logger) const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled - const candidates = [ - ...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]), - ].filter((candidate) => candidate === undefined || existsSync(candidate)) - const discovered = await Promise.all( - candidates.map(async (candidate) => ({ - stateHome: candidate, - url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })), - })), - ) - const found = discovered.find((candidate) => candidate.url !== undefined) + const existing = serviceUrl(await run(binary, ["service", "status"], logger, { stateHome })) logger.log("v2 CLI background instance checked", { - detected: Boolean(found), - ...endpoint(found?.url), + detected: Boolean(existing), + ...endpoint(existing), }) - const daemonStateHome = found?.stateHome ?? stateHome - const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome }) + const url = await run(binary, ["service", "start"], logger, { stateHome }) const password = await run(binary, ["service", "get", "password"], logger, { redact: true, - stateHome: daemonStateHome, + stateHome, }) logger.log("v2 CLI background service ready", { - existing: Boolean(found), + existing: Boolean(existing), username: "opencode", ...endpoint(url), }) diff --git a/packages/desktop/src/main/constants.ts b/packages/desktop/src/main/constants.ts index 258deb7c6d..9d43a5b87b 100644 --- a/packages/desktop/src/main/constants.ts +++ b/packages/desktop/src/main/constants.ts @@ -4,4 +4,5 @@ type Channel = "dev" | "beta" | "prod" const raw = import.meta.env.OPENCODE_CHANNEL export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev" -export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev" +export const UPDATER_ENABLED = + app.isPackaged && CHANNEL !== "dev" && import.meta.env.MOBILECODE_UPDATER_ENABLED === "true" diff --git a/packages/desktop/src/main/env.d.ts b/packages/desktop/src/main/env.d.ts index d69e6feec2..2614f04d01 100644 --- a/packages/desktop/src/main/env.d.ts +++ b/packages/desktop/src/main/env.d.ts @@ -1,4 +1,5 @@ interface ImportMetaEnv { + readonly MOBILECODE_UPDATER_ENABLED: string readonly OPENCODE_CHANNEL: string } diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 6a06a50830..9c90b51be6 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -11,6 +11,7 @@ import { app, BrowserWindow } from "electron" import { Deferred, Effect, Fiber } from "effect" import contextMenu from "electron-context-menu" +import { DESKTOP_APP_IDS, DESKTOP_APP_NAMES } from "../../product" import type { ServerReadyData } from "../preload/types" import { checkAppExists, resolveAppPath } from "./apps" import { CHANNEL } from "./constants" @@ -50,16 +51,6 @@ import { cleanupStoreFiles } from "./store-cleanup" import { startBackgroundCli } from "./background-cli" import { setNativeTranslations } from "./native-translations" -const APP_NAMES: Record = { - dev: "MobileCode Dev", - beta: "MobileCode Beta", - prod: "MobileCode", -} -const APP_IDS: Record = { - dev: "dev.mobilecode.desktop.dev", - beta: "dev.mobilecode.desktop.beta", - prod: "dev.mobilecode.desktop", -} const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1" const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" @@ -122,11 +113,11 @@ const main = Effect.gen(function* () { process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true" - const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" + const appId = app.isPackaged ? DESKTOP_APP_IDS[CHANNEL] : DESKTOP_APP_IDS.dev const onboardingTestRoot = ((): string | undefined => { if (!TEST_ONBOARDING) return - const root = join(tmpdir(), `opencode-onboarding-${randomUUID()}`) + const root = join(tmpdir(), `mobilecode-onboarding-${randomUUID()}`) rmSync(root, { recursive: true, force: true }) ;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) => mkdirSync(join(root, dir), { recursive: true }), @@ -138,13 +129,11 @@ const main = Effect.gen(function* () { process.env.XDG_STATE_HOME = join(root, "state") return root })() - app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "MobileCode Dev") + app.setName(app.isPackaged ? DESKTOP_APP_NAMES[CHANNEL] : DESKTOP_APP_NAMES.dev) app.setAppUserModelId(appId) - app.setPath( - "userData", - onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId), - ) - if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session")) + const userDataPath = onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId) + app.setPath("userData", userDataPath) + app.setPath("sessionData", onboardingTestRoot ? join(onboardingTestRoot, "session") : join(userDataPath, "session")) initializeOldLayoutEligibility(app.getPath("userData")) logger = initLogging() initCrashReporter() @@ -200,7 +189,7 @@ const main = Effect.gen(function* () { return } - const shellEnv = preferAppEnv(app.getPath("userData")) + preferAppEnv(app.getPath("userData")) app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("mobilecode://")) @@ -332,7 +321,7 @@ const main = Effect.gen(function* () { if (SIDECAR_VERSION === "v2") { logger.log("spawning v2 sidecar") - const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) + const sidecar = yield* Effect.promise(() => startBackgroundCli(logger)) yield* Deferred.succeed(serverReady, { url: sidecar.url, username: sidecar.username, diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index b8fc260cb8..875807b935 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -54,7 +54,7 @@ export async function exportDebugLogs() { await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error })) } - const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`) + const output = join(app.getPath("downloads"), `mobilecode-debug-${stamp()}.zip`) try { write("main", "exporting debug logs", { output }) await writeZip(output, [ diff --git a/packages/desktop/src/main/migrate.ts b/packages/desktop/src/main/migrate.ts index 70e3dc9c75..acd72b5855 100644 --- a/packages/desktop/src/main/migrate.ts +++ b/packages/desktop/src/main/migrate.ts @@ -3,10 +3,12 @@ import log from "electron-log/main.js" import { existsSync, readdirSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" +import { DESKTOP_APP_IDS } from "../../product" import { CHANNEL } from "./constants" import { getStore } from "./store" const TAURI_MIGRATED_KEY = "tauriMigrated" +const xdgDataHome = process.env.XDG_DATA_HOME // Resolve the directory where Tauri stored its .dat files for the given app identifier. // Mirrors Tauri's AppLocalData / AppData resolution per OS. @@ -17,18 +19,12 @@ function tauriDir(id: string) { case "win32": return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id) default: - return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id) + return join(xdgDataHome ?? join(homedir(), ".local", "share"), id) } } -// The Tauri app identifier changes between dev/beta/prod builds. -const TAURI_APP_IDS: Record = { - dev: "ai.opencode.desktop.dev", - beta: "ai.opencode.desktop.beta", - prod: "ai.opencode.desktop", -} function tauriAppId() { - return app.isPackaged ? TAURI_APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" + return app.isPackaged ? DESKTOP_APP_IDS[CHANNEL] : DESKTOP_APP_IDS.dev } // Migrate a single Tauri .dat file into the corresponding electron-store. diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index ae1a98efdf..cc415f864f 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -49,7 +49,10 @@ export function preferAppEnv(userDataPath: string) { OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_CLIENT: "desktop", - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + XDG_DATA_HOME: join(userDataPath, "data"), + XDG_CONFIG_HOME: join(userDataPath, "config"), + XDG_CACHE_HOME: join(userDataPath, "cache"), + XDG_STATE_HOME: join(userDataPath, "state"), }) return shellEnv } diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts index 246871fb2b..6199639e0b 100644 --- a/packages/desktop/src/main/sidecar.ts +++ b/packages/desktop/src/main/sidecar.ts @@ -1,4 +1,5 @@ import * as http from "node:http" +import { join } from "node:path" import * as tls from "node:tls" type NodeHttpWithEnvProxy = typeof http & { @@ -84,7 +85,10 @@ function prepareSidecarEnv(password: string, userDataPath: string) { Object.assign(process.env, { OPENCODE_SERVER_USERNAME: "opencode", OPENCODE_SERVER_PASSWORD: password, - XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, + XDG_DATA_HOME: join(userDataPath, "data"), + XDG_CONFIG_HOME: join(userDataPath, "config"), + XDG_CACHE_HOME: join(userDataPath, "cache"), + XDG_STATE_HOME: join(userDataPath, "state"), }) } diff --git a/packages/desktop/src/main/store.ts b/packages/desktop/src/main/store.ts index 687429ed43..facfbca4da 100644 --- a/packages/desktop/src/main/store.ts +++ b/packages/desktop/src/main/store.ts @@ -11,7 +11,7 @@ const cache = new Map() // We cannot instantiate the electron-store at module load time because // module import hoisting causes this to run before app.setPath("userData", ...) // in index.ts has executed, which would result in files being written to the default directory -// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings). +// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\dev.mobilecode.desktop.dev\opencode.settings). export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached diff --git a/script/build-macos.ts b/script/build-macos.ts index 3ca03afe0d..9ac33cebf6 100644 --- a/script/build-macos.ts +++ b/script/build-macos.ts @@ -35,7 +35,9 @@ const value = (name: string) => { } const known = ["--channel", "--arch", "--sign", "--skip-install", "--help", "-h"] -const unknown = args.find((arg) => arg.startsWith("-") && !known.some((flag) => arg === flag || arg.startsWith(`${flag}=`))) +const unknown = args.find( + (arg) => arg.startsWith("-") && !known.some((flag) => arg === flag || arg.startsWith(`${flag}=`)), +) if (unknown) { console.error(`Unknown option: ${unknown}\n\n${usage}`) process.exit(1) @@ -61,39 +63,43 @@ if (process.platform !== "darwin") { const root = path.resolve(import.meta.dir, "..") const desktop = path.join(root, "packages", "desktop") -// electron-vite, electron-builder and @electron/rebuild all run under Node, and Vite needs -// Node 20.19+. Prefer whatever is on PATH and fall back to the newest nvm install. +// electron-builder loads ESM dependencies through require(), which Node supports from 22.12. +// Prefer whatever is on PATH and fall back to the newest compatible nvm install. function ensureNode() { const version = (binary: string) => { const result = Bun.spawnSync([binary, "--version"], { stdout: "pipe", stderr: "ignore" }) if (!result.success) return undefined - const major = Number(/^v(\d+)/.exec(result.stdout.toString().trim())?.[1]) - return Number.isInteger(major) ? major : undefined + const parsed = /^v(\d+)\.(\d+)/.exec(result.stdout.toString().trim()) + if (!parsed) return undefined + return { major: Number(parsed[1]), minor: Number(parsed[2]), label: result.stdout.toString().trim() } } const current = version("node") - if (current !== undefined && current >= 20) return current + if (current && (current.major > 22 || (current.major === 22 && current.minor >= 12))) return current.label const versions = path.join(os.homedir(), ".nvm", "versions", "node") const candidates = existsSync(versions) ? readdirSync(versions) - .map((name) => ({ name, major: Number(/^v(\d+)/.exec(name)?.[1]) })) - .filter((entry) => Number.isInteger(entry.major) && entry.major >= 20) - .sort((a, b) => b.major - a.major) + .map((name) => ({ name, version: version(path.join(versions, name, "bin", "node")) })) + .filter((entry) => + Boolean( + entry.version && (entry.version.major > 22 || (entry.version.major === 22 && entry.version.minor >= 12)), + ), + ) + .sort((a, b) => b.name.localeCompare(a.name, undefined, { numeric: true })) : [] for (const candidate of candidates) { const bin = path.join(versions, candidate.name, "bin") - if (!existsSync(path.join(bin, "node"))) continue process.env["PATH"] = `${bin}:${process.env["PATH"] ?? ""}` console.log(` using Node ${candidate.name} from nvm`) - return candidate.major + return candidate.name } console.error( current === undefined - ? "Node.js was not found on PATH. Install Node 20 or newer and try again." - : `Node ${current} is too old. This build needs Node 20 or newer; install one and try again.`, + ? "Node.js was not found on PATH. Install Node 22.12 or newer and try again." + : `Node ${current.label} is too old. This build needs Node 22.12 or newer; install one and try again.`, ) process.exit(1) } @@ -105,6 +111,7 @@ const node = ensureNode() console.log(` node ${node}, bun ${Bun.version}, channel ${channel}, arch ${arch}`) process.env["OPENCODE_CHANNEL"] = channel +process.env["MOBILECODE_UPDATER_ENABLED"] = args.includes("--sign") ? "true" : "false" if (!args.includes("--sign")) { // Ad-hoc signing only. Gatekeeper will ask on first launch. process.env["CSC_IDENTITY_AUTO_DISCOVERY"] = "false"