Skip to content

Commit 1a23438

Browse files
authored
fix(desktop): release script (#6060)
* fix(desktop): fix release * fix * Updates * chore(copilot): sync generated mothership contract mirrors (user_table select descriptions from mothership #373) * fix(desktop): canonicalize the apex origin in setOrigin, not just on load
1 parent 4f776b7 commit 1a23438

10 files changed

Lines changed: 429 additions & 15 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"start": "electron .",
1919
"package:dir": "bun run build && electron-builder --mac dir --publish never",
2020
"package:mac": "bun run build && electron-builder --mac --publish never",
21-
"package:share": "bun run build && electron-builder --mac --publish never -c.mac.timestamp=none",
21+
"package:share": "bun run scripts/package-share.ts",
2222
"install:local": "bun run scripts/install-local.ts",
2323
"type-check": "tsc --noEmit",
2424
"lint": "biome check --write --unsafe .",

apps/desktop/scripts/channels.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Build-time channel identity, derived from the origin a build is baked to.
3+
*
4+
* Must stay in sync with APP_NAME_FOR_CHANNEL / channelForOrigin in
5+
* src/main/config.ts. That pair is the RUNTIME source of truth — the app calls
6+
* app.setName() from its baked origin at startup, which decides the userData
7+
* directory and single-instance lock — and this is what the packager stamps
8+
* into the bundle. When the two disagree, the bundle on disk and the running
9+
* app disagree about which app they are: a dev-pointed build packaged as
10+
* "Sim.app" with the production bundle id installs over a real production
11+
* install while naming itself "Sim Dev" at runtime.
12+
*/
13+
14+
/** Canonical no-redirect origins per environment. */
15+
export const PROD_ORIGIN = 'https://www.sim.ai'
16+
export const STAGING_ORIGIN = 'https://www.staging.sim.ai'
17+
export const DEV_ORIGIN = 'https://www.dev.sim.ai'
18+
export const LOCAL_ORIGIN = 'http://localhost:3000'
19+
20+
export interface ChannelIdentity {
21+
/** Display + bundle name; also the userData directory name. */
22+
name: string
23+
appId: string
24+
/** Baked default origin + persisted settings origin. */
25+
origin: string
26+
/**
27+
* Artifact filename stem, and the per-channel scratch directory name.
28+
* Space-free for the same reason electron-builder.yml's artifactName is:
29+
* GitHub rewrites asset names containing spaces, which desyncs the
30+
* electron-updater manifest from the uploaded files.
31+
*/
32+
slug: string
33+
}
34+
35+
export const PROD: ChannelIdentity = {
36+
name: 'Sim',
37+
appId: 'ai.sim.desktop',
38+
origin: PROD_ORIGIN,
39+
slug: 'sim',
40+
}
41+
export const STAGING: ChannelIdentity = {
42+
name: 'Sim Staging',
43+
appId: 'ai.sim.desktop.staging',
44+
origin: STAGING_ORIGIN,
45+
slug: 'sim-staging',
46+
}
47+
export const DEV: ChannelIdentity = {
48+
name: 'Sim Dev',
49+
appId: 'ai.sim.desktop.dev',
50+
origin: DEV_ORIGIN,
51+
slug: 'sim-dev',
52+
}
53+
export const LOCAL: ChannelIdentity = {
54+
name: 'Sim Local',
55+
appId: 'ai.sim.desktop.local',
56+
origin: LOCAL_ORIGIN,
57+
slug: 'sim-local',
58+
}
59+
60+
/** Every channel, in the order a full run builds them. */
61+
export const ALL_CHANNELS: readonly ChannelIdentity[] = [PROD, STAGING, DEV, LOCAL]
62+
63+
/**
64+
* Resolves the identity a build baked to `origin` must carry. Mirrors
65+
* channelForOrigin in src/main/config.ts, including its fall-through to
66+
* production for unrecognized hosts (self-hosted origins are production
67+
* builds pointed elsewhere). An empty origin means "unset", which the app
68+
* resolves to DEFAULT_ORIGIN — production.
69+
*/
70+
export function identityForOrigin(origin: string): ChannelIdentity {
71+
if (!origin) return PROD
72+
let host: string
73+
try {
74+
host = new URL(origin).hostname.toLowerCase()
75+
} catch {
76+
return PROD
77+
}
78+
if (host === 'localhost' || host === '127.0.0.1') return LOCAL
79+
if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) return DEV
80+
if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) return STAGING
81+
return PROD
82+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Share build: packages distributable .dmg/.zip artifacts from the current
3+
* checkout, signed with whatever identity is on the machine (no notarization,
4+
* no trusted timestamps) — the "send someone a build to try" loop.
5+
*
6+
* bun run package:share # production only
7+
* bun run package:share --all # all four channels
8+
* bun run package:share --staging --dev # just these two
9+
* bun run package:share --dir # skip dmg/zip, package the .app only (fast)
10+
* SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.acme.example bun run package:share
11+
*
12+
* Each channel lands in release/<slug>/ with artifacts named for it — sim,
13+
* sim-staging, sim-dev, sim-local. electron-builder.yml's artifactName is a
14+
* single literal ("Sim-${version}-${arch}"), so without the override every
15+
* channel writes the same filename and the last build silently wins. Only this
16+
* path overrides it: the release workflow publishes one channel per GitHub
17+
* release, where the flat name is what electron-updater expects.
18+
*
19+
* The baked origin decides the bundle identity, exactly as it decides the
20+
* runtime one. This used to shell straight into electron-builder, which took
21+
* productName/appId from electron-builder.yml — always the production pair — so
22+
* a dev-pointed share packaged as "Sim.app" with the production bundle id while
23+
* naming itself "Sim Dev" at runtime.
24+
*
25+
* Channels build ONE AT A TIME on purpose. scripts/build.ts writes the bundle
26+
* to dist/ and the app icon to build/generated-icon.icns, both fixed paths, so
27+
* concurrent channels would overwrite each other's bundle mid-package and ship
28+
* a dmg whose baked origin belongs to a different environment — invisible until
29+
* someone signs in. Giving each channel its own bundle directory is what would
30+
* make concurrency safe, and the flag that redirects the app entry point
31+
* (-c.extraMetadata.main) rewrites this package.json IN THE SOURCE TREE,
32+
* stripping scripts and devDependencies. If parallelism is worth it later, the
33+
* way to get it is a per-channel project directory (electron-builder --project)
34+
* — not extraMetadata.
35+
*/
36+
import { spawnSync } from 'node:child_process'
37+
import { rmSync } from 'node:fs'
38+
import { ALL_CHANNELS, type ChannelIdentity, identityForOrigin } from './channels'
39+
40+
const FLAG_TO_CHANNEL: Record<string, ChannelIdentity> = Object.fromEntries(
41+
ALL_CHANNELS.map((channel) => [
42+
`--${channel.slug.replace(/^sim-/, '').replace(/^sim$/, 'prod')}`,
43+
channel,
44+
])
45+
)
46+
47+
const args = process.argv.slice(2)
48+
const dirOnly = args.includes('--dir')
49+
const bakedOriginOverride = process.env.SIM_DESKTOP_DEFAULT_ORIGIN ?? ''
50+
51+
function selectedChannels(): ChannelIdentity[] {
52+
if (args.includes('--all')) return [...ALL_CHANNELS]
53+
const picked = args.filter((arg) => arg in FLAG_TO_CHANNEL).map((arg) => FLAG_TO_CHANNEL[arg])
54+
if (picked.length > 0) return picked
55+
// No channel flags: honour an explicit origin (self-hosted shares resolve to
56+
// the production identity), otherwise plain production.
57+
return [identityForOrigin(bakedOriginOverride)]
58+
}
59+
60+
const channels = selectedChannels()
61+
// An explicit origin only makes sense for a single-channel run; with several
62+
// channels each one supplies its own.
63+
const originFor = (channel: ChannelIdentity): string =>
64+
channels.length === 1 && bakedOriginOverride ? bakedOriginOverride : channel.origin
65+
66+
function run(command: string, commandArgs: string[], env?: Record<string, string>): void {
67+
const result = spawnSync(command, commandArgs, {
68+
stdio: 'inherit',
69+
env: env ? { ...process.env, ...env } : process.env,
70+
})
71+
if (result.status !== 0) {
72+
console.error(`\n✖ ${command} ${commandArgs.join(' ')} failed`)
73+
process.exit(result.status ?? 1)
74+
}
75+
}
76+
77+
function buildChannel(channel: ChannelIdentity): void {
78+
const origin = originFor(channel)
79+
console.log(`\n• ${channel.slug}: ${channel.name} (${channel.appId}) → ${origin}`)
80+
81+
// electron-builder only writes the output dir for the CURRENT target/arch, so
82+
// an app left by an earlier run with different settings would survive
83+
// alongside the new one.
84+
for (const dir of ['mac-universal', 'mac-arm64', 'mac']) {
85+
rmSync(`release/${channel.slug}/${dir}`, { recursive: true, force: true })
86+
}
87+
// electron-builder's `files: dist/**` packages whatever is in dist/, not just
88+
// what this build wrote. Anything left there by an earlier run — another
89+
// channel's bundle, scratch from an experiment — rides along inside the dmg,
90+
// so a production artifact can end up carrying a dev-pointed bundle. Dead
91+
// weight rather than a live risk (the app boots package.json's `main`), but
92+
// not something to hand to anyone. build.ts rewrites the directory on the
93+
// next line.
94+
rmSync('dist', { recursive: true, force: true })
95+
96+
run('bun', ['run', 'scripts/build.ts'], { SIM_DESKTOP_DEFAULT_ORIGIN: origin })
97+
run('bunx', [
98+
'electron-builder',
99+
'--mac',
100+
...(dirOnly ? ['dir'] : []),
101+
'--publish',
102+
'never',
103+
// Trusted timestamps make codesign do a network round trip to Apple per
104+
// file (hundreds inside the Electron framework). Distribution builds need
105+
// them; a share build does not, and it turns signing into a long stall.
106+
'-c.mac.timestamp=none',
107+
`-c.productName=${channel.name}`,
108+
`-c.appId=${channel.appId}`,
109+
`-c.directories.output=release/${channel.slug}`,
110+
// The ${...} placeholders are electron-builder's own templating, expanded
111+
// by it at packaging time — escaped here so JS leaves them alone.
112+
`-c.artifactName=${channel.slug}-\${version}-\${arch}.\${ext}`,
113+
])
114+
console.log(`✔ ${channel.slug}: release/${channel.slug}/`)
115+
}
116+
117+
// Shared node_modules state, and the one step every channel has in common.
118+
run('bun', ['run', 'scripts/ensure-pty-prebuilds.ts'])
119+
console.log(
120+
`• Building ${channels.length} channel(s)${dirOnly ? ' (dir only)' : ''}: ${channels.map((c) => c.slug).join(', ')}`
121+
)
122+
for (const channel of channels) {
123+
buildChannel(channel)
124+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { APP_NAME_FOR_CHANNEL, channelForOrigin, DEFAULT_ORIGIN } from '@/main/config'
3+
import { classifyNavigation } from '@/main/navigation'
4+
import { DEV, identityForOrigin, LOCAL, PROD, STAGING } from '../../scripts/channels'
5+
6+
/**
7+
* The packager stamps a bundle name/id from scripts/channels.ts while the app
8+
* names itself at runtime from config.ts. Nothing linked the two, so they were
9+
* free to drift — which is how the share build ended up packaging a
10+
* dev-pointed app under the production identity.
11+
*/
12+
describe('channel identity', () => {
13+
it('packages every channel under the name it gives itself at runtime', () => {
14+
for (const identity of [PROD, STAGING, DEV, LOCAL]) {
15+
expect(APP_NAME_FOR_CHANNEL[channelForOrigin(identity.origin)]).toBe(identity.name)
16+
}
17+
})
18+
19+
it('resolves an unset baked origin to the same channel as the app default', () => {
20+
expect(identityForOrigin('')).toBe(PROD)
21+
expect(PROD.origin).toBe(DEFAULT_ORIGIN)
22+
expect(channelForOrigin(DEFAULT_ORIGIN)).toBe('prod')
23+
})
24+
25+
it('treats an unrecognized (self-hosted) origin as production', () => {
26+
expect(identityForOrigin('https://sim.acme-corp.example')).toBe(PROD)
27+
})
28+
})
29+
30+
/**
31+
* The shipped default must be an origin the environment SERVES. When it was
32+
* the apex (which 301s to www) the window sat one origin away from appOrigin,
33+
* so `fromAuthSurface` was never true and social login was misread as an
34+
* integration connect — the "finish connecting in your browser" dialog
35+
* instead of the login handoff.
36+
*/
37+
describe('social login from the shipped default origin', () => {
38+
it('hands off to the system browser instead of offering a connect', () => {
39+
expect(
40+
classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?client_id=x', {
41+
appOrigin: DEFAULT_ORIGIN,
42+
currentUrl: `${DEFAULT_ORIGIN}/login`,
43+
})
44+
).toBe('idp-system-login')
45+
})
46+
47+
it('still offers the connect dialog for an integration connect mid-workspace', () => {
48+
expect(
49+
classifyNavigation('https://accounts.google.com/o/oauth2/v2/auth?client_id=x', {
50+
appOrigin: DEFAULT_ORIGIN,
51+
currentUrl: `${DEFAULT_ORIGIN}/workspace/ws1/w/wf1`,
52+
})
53+
).toBe('idp-system-connect')
54+
})
55+
})

apps/desktop/src/main/config.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from 'node:path'
44
import { describe, expect, it } from 'vitest'
55
import {
66
APP_NAME_FOR_CHANNEL,
7+
canonicalOrigin,
78
channelForOrigin,
89
createConfigStore,
910
DEFAULT_ORIGIN,
@@ -58,6 +59,27 @@ describe('partitionForOrigin', () => {
5859
})
5960
})
6061

62+
describe('canonicalOrigin', () => {
63+
it('rewrites the pre-www production origin', () => {
64+
expect(canonicalOrigin('https://sim.ai')).toBe('https://www.sim.ai')
65+
})
66+
67+
it('leaves every other origin alone', () => {
68+
expect(canonicalOrigin('https://www.sim.ai')).toBe('https://www.sim.ai')
69+
expect(canonicalOrigin('https://www.staging.sim.ai')).toBe('https://www.staging.sim.ai')
70+
expect(canonicalOrigin('https://sim.acme-corp.example')).toBe('https://sim.acme-corp.example')
71+
expect(canonicalOrigin('http://localhost:3000')).toBe('http://localhost:3000')
72+
})
73+
74+
it('keeps a rewritten install on its existing cookie partition', () => {
75+
// The whole point of rewriting rather than leaving the apex in place: the
76+
// apex no longer equals DEFAULT_ORIGIN, so an un-rewritten install would
77+
// be moved to a fresh empty jar and silently signed out on update.
78+
expect(partitionForOrigin(canonicalOrigin('https://sim.ai'))).toBe('persist:sim')
79+
expect(partitionForOrigin('https://sim.ai')).not.toBe('persist:sim')
80+
})
81+
})
82+
6183
describe('isSafeInternalPath', () => {
6284
it('accepts absolute same-origin paths with query', () => {
6385
expect(isSafeInternalPath('/workspace/ws1?tab=logs')).toBe(true)
@@ -76,6 +98,26 @@ describe('isSafeInternalPath', () => {
7698
})
7799

78100
describe('createConfigStore', () => {
101+
it('rewrites a stored pre-www production origin and persists it immediately', () => {
102+
const filePath = tempSettingsPath()
103+
writeFileSync(filePath, JSON.stringify({ origin: 'https://sim.ai', zoomLevel: 1.5 }))
104+
105+
const store = createConfigStore(filePath, {})
106+
107+
expect(store.getOrigin()).toBe('https://www.sim.ai')
108+
expect(store.get('zoomLevel')).toBe(1.5)
109+
// Written without waiting for the debounce, so a crash cannot leave the
110+
// file pointing somewhere the running app is not.
111+
expect(JSON.parse(readFileSync(filePath, 'utf8')).origin).toBe('https://www.sim.ai')
112+
})
113+
114+
it('leaves a deliberately configured origin untouched', () => {
115+
const filePath = tempSettingsPath()
116+
writeFileSync(filePath, JSON.stringify({ origin: 'https://sim.acme-corp.example' }))
117+
118+
expect(createConfigStore(filePath, {}).getOrigin()).toBe('https://sim.acme-corp.example')
119+
})
120+
79121
it('round-trips settings through disk', () => {
80122
const filePath = tempSettingsPath()
81123
const store = createConfigStore(filePath, {})
@@ -103,6 +145,20 @@ describe('createConfigStore', () => {
103145
expect(reloaded.getOrigin()).toBe('https://self-hosted.example')
104146
})
105147

148+
it('canonicalizes the apex production origin on setOrigin, not just on load', () => {
149+
// Entering https://sim.ai mid-session must not persist the apex: the
150+
// running session would use the wrong cookie partition and misclassify
151+
// social login until the next launch's load-time rewrite repaired it.
152+
const filePath = tempSettingsPath()
153+
const store = createConfigStore(filePath, {})
154+
const result = store.setOrigin('https://sim.ai')
155+
expect(result).toEqual({ ok: true, origin: 'https://www.sim.ai' })
156+
expect(store.getOrigin()).toBe('https://www.sim.ai')
157+
158+
const reloaded = createConfigStore(filePath, {})
159+
expect(reloaded.getOrigin()).toBe('https://www.sim.ai')
160+
})
161+
106162
it('recovers from a corrupted settings file', () => {
107163
const filePath = tempSettingsPath()
108164
writeFileSync(filePath, '{not json')

0 commit comments

Comments
 (0)