Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"start": "electron .",
"package:dir": "bun run build && electron-builder --mac dir --publish never",
"package:mac": "bun run build && electron-builder --mac --publish never",
"package:share": "bun run build && electron-builder --mac --publish never -c.mac.timestamp=none",
"package:share": "bun run scripts/package-share.ts",
"install:local": "bun run scripts/install-local.ts",
"type-check": "tsc --noEmit",
"lint": "biome check --write --unsafe .",
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/scripts/channels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Build-time channel identity, derived from the origin a build is baked to.
*
* Must stay in sync with APP_NAME_FOR_CHANNEL / channelForOrigin in
* src/main/config.ts. That pair is the RUNTIME source of truth — the app calls
* app.setName() from its baked origin at startup, which decides the userData
* directory and single-instance lock — and this is what the packager stamps
* into the bundle. When the two disagree, the bundle on disk and the running
* app disagree about which app they are: a dev-pointed build packaged as
* "Sim.app" with the production bundle id installs over a real production
* install while naming itself "Sim Dev" at runtime.
*/

/** Canonical no-redirect origins per environment. */
export const PROD_ORIGIN = 'https://www.sim.ai'
export const STAGING_ORIGIN = 'https://www.staging.sim.ai'
export const DEV_ORIGIN = 'https://www.dev.sim.ai'
export const LOCAL_ORIGIN = 'http://localhost:3000'

export interface ChannelIdentity {
/** Display + bundle name; also the userData directory name. */
name: string
appId: string
/** Baked default origin + persisted settings origin. */
origin: string
/**
* Artifact filename stem, and the per-channel scratch directory name.
* Space-free for the same reason electron-builder.yml's artifactName is:
* GitHub rewrites asset names containing spaces, which desyncs the
* electron-updater manifest from the uploaded files.
*/
slug: string
}

export const PROD: ChannelIdentity = {
name: 'Sim',
appId: 'ai.sim.desktop',
origin: PROD_ORIGIN,
slug: 'sim',
}
export const STAGING: ChannelIdentity = {
name: 'Sim Staging',
appId: 'ai.sim.desktop.staging',
origin: STAGING_ORIGIN,
slug: 'sim-staging',
}
export const DEV: ChannelIdentity = {
name: 'Sim Dev',
appId: 'ai.sim.desktop.dev',
origin: DEV_ORIGIN,
slug: 'sim-dev',
}
export const LOCAL: ChannelIdentity = {
name: 'Sim Local',
appId: 'ai.sim.desktop.local',
origin: LOCAL_ORIGIN,
slug: 'sim-local',
}

/** Every channel, in the order a full run builds them. */
export const ALL_CHANNELS: readonly ChannelIdentity[] = [PROD, STAGING, DEV, LOCAL]

/**
* Resolves the identity a build baked to `origin` must carry. Mirrors
* channelForOrigin in src/main/config.ts, including its fall-through to
* production for unrecognized hosts (self-hosted origins are production
* builds pointed elsewhere). An empty origin means "unset", which the app
* resolves to DEFAULT_ORIGIN — production.
*/
export function identityForOrigin(origin: string): ChannelIdentity {
if (!origin) return PROD
let host: string
try {
host = new URL(origin).hostname.toLowerCase()
} catch {
return PROD
}
if (host === 'localhost' || host === '127.0.0.1') return LOCAL
if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return DEV
if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) return STAGING
return PROD
}
124 changes: 124 additions & 0 deletions apps/desktop/scripts/package-share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Share build: packages distributable .dmg/.zip artifacts from the current
* checkout, signed with whatever identity is on the machine (no notarization,
* no trusted timestamps) — the "send someone a build to try" loop.
*
* bun run package:share # production only
* bun run package:share --all # all four channels
* bun run package:share --staging --dev # just these two
* bun run package:share --dir # skip dmg/zip, package the .app only (fast)
* SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.acme.example bun run package:share
*
* Each channel lands in release/<slug>/ with artifacts named for it — sim,
* sim-staging, sim-dev, sim-local. electron-builder.yml's artifactName is a
* single literal ("Sim-${version}-${arch}"), so without the override every
* channel writes the same filename and the last build silently wins. Only this
* path overrides it: the release workflow publishes one channel per GitHub
* release, where the flat name is what electron-updater expects.
*
* The baked origin decides the bundle identity, exactly as it decides the
* runtime one. This used to shell straight into electron-builder, which took
* productName/appId from electron-builder.yml — always the production pair — so
* a dev-pointed share packaged as "Sim.app" with the production bundle id while
* naming itself "Sim Dev" at runtime.
*
* Channels build ONE AT A TIME on purpose. scripts/build.ts writes the bundle
* to dist/ and the app icon to build/generated-icon.icns, both fixed paths, so
* concurrent channels would overwrite each other's bundle mid-package and ship
* a dmg whose baked origin belongs to a different environment — invisible until
* someone signs in. Giving each channel its own bundle directory is what would
* make concurrency safe, and the flag that redirects the app entry point
* (-c.extraMetadata.main) rewrites this package.json IN THE SOURCE TREE,
* stripping scripts and devDependencies. If parallelism is worth it later, the
* way to get it is a per-channel project directory (electron-builder --project)
* — not extraMetadata.
*/
import { spawnSync } from 'node:child_process'
import { rmSync } from 'node:fs'
import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels'

const FLAG_TO_CHANNEL: Record<string, ChannelIdentity> = Object.fromEntries(
ALL_CHANNELS.map((channel) => [
`--${channel.slug.replace(/^sim-/, '').replace(/^sim$/, 'prod')}`,
channel,
])
)

const args = process.argv.slice(2)
const dirOnly = args.includes('--dir')
const bakedOriginOverride = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? ''

function selectedChannels(): ChannelIdentity[] {
if (args.includes('--all')) return [...ALL_CHANNELS]
const picked = args.filter((arg) => arg in FLAG_TO_CHANNEL).map((arg) => FLAG_TO_CHANNEL[arg])
if (picked.length > 0) return picked
// No channel flags: honour an explicit origin (self-hosted shares resolve to
// the production identity), otherwise plain production.
return [identityForOrigin(bakedOriginOverride)]
}

const channels = selectedChannels()
// An explicit origin only makes sense for a single-channel run; with several
// channels each one supplies its own.
const originFor = (channel: ChannelIdentity): string =>
channels.length === 1 && bakedOriginOverride ? bakedOriginOverride : channel.origin

function run(command: string, commandArgs: string[], env?: Record<string, string>): void {
const result = spawnSync(command, commandArgs, {
stdio: 'inherit',
env: env ? { ...process.env, ...env } : process.env,
})
if (result.status !== 0) {
console.error(`\n✖ ${command} ${commandArgs.join(' ')} failed`)
process.exit(result.status ?? 1)
}
}

function buildChannel(channel: ChannelIdentity): void {
const origin = originFor(channel)
console.log(`\n• ${channel.slug}: ${channel.name} (${channel.appId}) → ${origin}`)

// electron-builder only writes the output dir for the CURRENT target/arch, so
// an app left by an earlier run with different settings would survive
// alongside the new one.
for (const dir of ['mac-universal', 'mac-arm64', 'mac']) {
rmSync(`release/${channel.slug}/${dir}`, { recursive: true, force: true })
}
// electron-builder's `files: dist/**` packages whatever is in dist/, not just
// what this build wrote. Anything left there by an earlier run — another
// channel's bundle, scratch from an experiment — rides along inside the dmg,
// so a production artifact can end up carrying a dev-pointed bundle. Dead
// weight rather than a live risk (the app boots package.json's `main`), but
// not something to hand to anyone. build.ts rewrites the directory on the
// next line.
rmSync('dist', { recursive: true, force: true })

run('bun', ['run', 'scripts/build.ts'], { SIM_DESKTOP_DEFAULT_ORIGIN: origin })
run('bunx', [
'electron-builder',
'--mac',
...(dirOnly ? ['dir'] : []),
'--publish',
'never',
// Trusted timestamps make codesign do a network round trip to Apple per
// file (hundreds inside the Electron framework). Distribution builds need
// them; a share build does not, and it turns signing into a long stall.
'-c.mac.timestamp=none',
`-c.productName=${channel.name}`,
`-c.appId=${channel.appId}`,
`-c.directories.output=release/${channel.slug}`,
// The ${...} placeholders are electron-builder's own templating, expanded
// by it at packaging time — escaped here so JS leaves them alone.
`-c.artifactName=${channel.slug}-\${version}-\${arch}.\${ext}`,
])
console.log(`✔ ${channel.slug}: release/${channel.slug}/`)
}

// Shared node_modules state, and the one step every channel has in common.
run('bun', ['run', 'scripts/ensure-pty-prebuilds.ts'])
console.log(
`• Building ${channels.length} channel(s)${dirOnly ? ' (dir only)' : ''}: ${channels.map((c) => c.slug).join(', ')}`
)
for (const channel of channels) {
buildChannel(channel)
}
55 changes: 55 additions & 0 deletions apps/desktop/src/main/channel-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { APP_NAME_FOR_CHANNEL, channelForOrigin, DEFAULT_ORIGIN } from '@/main/config'
import { classifyNavigation } from '@/main/navigation'
import { DEV, identityForOrigin, LOCAL, PROD, STAGING } from '../../scripts/channels'

/**
* The packager stamps a bundle name/id from scripts/channels.ts while the app
* names itself at runtime from config.ts. Nothing linked the two, so they were
* free to drift — which is how the share build ended up packaging a
* dev-pointed app under the production identity.
*/
describe('channel identity', () => {
it('packages every channel under the name it gives itself at runtime', () => {
for (const identity of [PROD, STAGING, DEV, LOCAL]) {
expect(APP_NAME_FOR_CHANNEL[channelForOrigin(identity.origin)]).toBe(identity.name)
}
})

it('resolves an unset baked origin to the same channel as the app default', () => {
expect(identityForOrigin('')).toBe(PROD)
expect(PROD.origin).toBe(DEFAULT_ORIGIN)
expect(channelForOrigin(DEFAULT_ORIGIN)).toBe('prod')
})

it('treats an unrecognized (self-hosted) origin as production', () => {
expect(identityForOrigin('https://sim.acme-corp.example')).toBe(PROD)
})
})

/**
* The shipped default must be an origin the environment SERVES. When it was
* the apex (which 301s to www) the window sat one origin away from appOrigin,
* so `fromAuthSurface` was never true and social login was misread as an
* integration connect — the "finish connecting in your browser" dialog
* instead of the login handoff.
*/
describe('social login from the shipped default origin', () => {
it('hands off to the system browser instead of offering a connect', () => {
expect(
classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?client_id=x', {
appOrigin: DEFAULT_ORIGIN,
currentUrl: `${DEFAULT_ORIGIN}/login`,
})
).toBe('idp-system-login')
})

it('still offers the connect dialog for an integration connect mid-workspace', () => {
expect(
classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?client_id=x', {
appOrigin: DEFAULT_ORIGIN,
currentUrl: `${DEFAULT_ORIGIN}/workspace/ws1/w/wf1`,
})
).toBe('idp-system-connect')
})
})
56 changes: 56 additions & 0 deletions apps/desktop/src/main/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
APP_NAME_FOR_CHANNEL,
canonicalOrigin,
channelForOrigin,
createConfigStore,
DEFAULT_ORIGIN,
Expand Down Expand Up @@ -58,6 +59,27 @@ describe('partitionForOrigin', () => {
})
})

describe('canonicalOrigin', () => {
it('rewrites the pre-www production origin', () => {
expect(canonicalOrigin('https://sim.ai')).toBe('https://www.sim.ai')
})

it('leaves every other origin alone', () => {
expect(canonicalOrigin('https://www.sim.ai')).toBe('https://www.sim.ai')
expect(canonicalOrigin('https://www.staging.sim.ai')).toBe('https://www.staging.sim.ai')
expect(canonicalOrigin('https://sim.acme-corp.example')).toBe('https://sim.acme-corp.example')
expect(canonicalOrigin('http://localhost:3000')).toBe('http://localhost:3000')
})

it('keeps a rewritten install on its existing cookie partition', () => {
// The whole point of rewriting rather than leaving the apex in place: the
// apex no longer equals DEFAULT_ORIGIN, so an un-rewritten install would
// be moved to a fresh empty jar and silently signed out on update.
expect(partitionForOrigin(canonicalOrigin('https://sim.ai'))).toBe('persist:sim')
expect(partitionForOrigin('https://sim.ai')).not.toBe('persist:sim')
})
})

describe('isSafeInternalPath', () => {
it('accepts absolute same-origin paths with query', () => {
expect(isSafeInternalPath('/workspace/ws1?tab=logs')).toBe(true)
Expand All @@ -76,6 +98,26 @@ describe('isSafeInternalPath', () => {
})

describe('createConfigStore', () => {
it('rewrites a stored pre-www production origin and persists it immediately', () => {
const filePath = tempSettingsPath()
writeFileSync(filePath, JSON.stringify({ origin: 'https://sim.ai', zoomLevel: 1.5 }))

const store = createConfigStore(filePath, {})

expect(store.getOrigin()).toBe('https://www.sim.ai')
expect(store.get('zoomLevel')).toBe(1.5)
// Written without waiting for the debounce, so a crash cannot leave the
// file pointing somewhere the running app is not.
expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://www.sim.ai')
})

it('leaves a deliberately configured origin untouched', () => {
const filePath = tempSettingsPath()
writeFileSync(filePath, JSON.stringify({ origin: 'https://sim.acme-corp.example' }))

expect(createConfigStore(filePath, {}).getOrigin()).toBe('https://sim.acme-corp.example')
})

it('round-trips settings through disk', () => {
const filePath = tempSettingsPath()
const store = createConfigStore(filePath, {})
Expand Down Expand Up @@ -103,6 +145,20 @@ describe('createConfigStore', () => {
expect(reloaded.getOrigin()).toBe('https://self-hosted.example')
})

it('canonicalizes the apex production origin on setOrigin, not just on load', () => {
// Entering https://sim.ai mid-session must not persist the apex: the
// running session would use the wrong cookie partition and misclassify
// social login until the next launch's load-time rewrite repaired it.
const filePath = tempSettingsPath()
const store = createConfigStore(filePath, {})
const result = store.setOrigin('https://sim.ai')
expect(result).toEqual({ ok: true, origin: 'https://www.sim.ai' })
expect(store.getOrigin()).toBe('https://www.sim.ai')

const reloaded = createConfigStore(filePath, {})
expect(reloaded.getOrigin()).toBe('https://www.sim.ai')
})

it('recovers from a corrupted settings file', () => {
const filePath = tempSettingsPath()
writeFileSync(filePath, '{not json')
Expand Down
Loading
Loading