Skip to content

Commit fd18729

Browse files
committed
fix bugs
1 parent 1dd85eb commit fd18729

24 files changed

Lines changed: 1206 additions & 448 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ jobs:
7272
# Detect shell-code changes on dev/staging pushes. Web-only changes never
7373
# need a desktop build (installed shells load the web app live); changes to
7474
# the Electron app or the bridge packages trigger a per-env prerelease build
75-
# (dev → alpha channel, staging → beta) that the env's update feed
75+
# (dev → dev stream, staging → staging) that the env's update feed
7676
# (/api/desktop/update) starts offering automatically.
7777
detect-desktop-changes:
7878
name: Detect Desktop Changes
@@ -707,10 +707,10 @@ jobs:
707707
secrets: inherit
708708

709709
# Per-env desktop prereleases: a dev/staging push that touches shell code
710-
# publishes a channel-tagged GitHub prerelease (vX.Y.Z-alpha.N from dev,
711-
# vX.Y.Z-beta.N from staging). Each environment's /api/desktop/update feed
712-
# offers only its channel, so dev-pointed shells pick up alpha builds,
713-
# staging-pointed shells beta builds, and prod-pointed shells stable
710+
# publishes an environment-tagged GitHub prerelease (vX.Y.Z-dev.N from dev,
711+
# vX.Y.Z-staging.N from staging). Each environment's /api/desktop/update feed
712+
# offers only its stream, so dev-pointed shells pick up dev builds,
713+
# staging-pointed shells staging builds, and prod-pointed shells stable
714714
# releases — independently. Unlike stable releases, prereleases build even
715715
# before the Apple signing secrets exist — unsigned, so the update pipeline
716716
# is testable end to end; installed shells detect the missing Developer ID
@@ -738,7 +738,7 @@ jobs:
738738
GH_REPO: ${{ github.repository }}
739739
SIGNED: ${{ needs.check-desktop-signing.outputs.configured }}
740740
run: |
741-
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; APP_NAME="Sim Dev"; else CHANNEL=beta; APP_NAME="Sim Staging"; fi
741+
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; APP_NAME="Sim Dev"; else CHANNEL=staging; APP_NAME="Sim Staging"; fi
742742
# Prerelease core = next patch after the latest stable release, so
743743
# channel builds always outrank the stable they are built on top of
744744
# and are always superseded by the next stable. The run-attempt
@@ -825,9 +825,9 @@ jobs:
825825
steps:
826826
- name: Delete stale prereleases
827827
run: |
828-
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi
828+
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi
829829
gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \
830-
--jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNEL}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |
830+
--jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" |
831831
while read -r TAG; do
832832
[ -n "$TAG" ] || continue
833833
echo "Deleting stale prerelease $TAG"
@@ -836,11 +836,11 @@ jobs:
836836
837837
- name: Delete leftover draft prereleases
838838
run: |
839-
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi
839+
if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi
840840
# Drafts have no tag ref, so delete by release id via the API
841841
# (gh release delete resolves by tag, which is ambiguous for drafts).
842842
gh api "repos/${GH_REPO}/releases?per_page=100" \
843-
--jq ".[] | select(.draft and (.tag_name | test(\"-${CHANNEL}\\\\.\"))) | .id" |
843+
--jq ".[] | select(.draft and (.tag_name | test(\"-${CHANNELS}\\\\.\"))) | .id" |
844844
while read -r ID; do
845845
[ -n "$ID" ] || continue
846846
echo "Deleting leftover draft release $ID"

.github/workflows/desktop-release.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ jobs:
9191
exit 1
9292
fi
9393
94-
# Prerelease versions carry their environment in the tag: -alpha.N is a
95-
# dev build, -beta.N a staging build. The channel decides the app's
94+
# Prerelease versions carry their environment in the tag: -dev.N is a
95+
# dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain
96+
# accepted while already-published builds age out. The channel decides the app's
9697
# identity (name/bundle id — a separate app per environment, installable
9798
# side by side) and the default origin baked into the bundle, which in
9899
# turn selects the update feed the installed app polls.
@@ -102,9 +103,9 @@ jobs:
102103
VERSION: ${{ inputs.version }}
103104
run: |
104105
case "$VERSION" in
105-
*-alpha.*)
106+
*-dev.*|*-alpha.*)
106107
NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;;
107-
*-beta.*)
108+
*-staging.*|*-beta.*)
108109
NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;;
109110
*)
110111
NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;;

apps/desktop/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
168168
## Auto-update, channels, rollout, rollback
169169

170170
- `electron-updater` reads the GitHub Releases feed (`publish` is pinned to `simstudioai/sim`); deltas via `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session.
171-
- Channels: stable builds (`X.Y.Z`) follow `latest`; `-beta.N` builds follow `beta` (never attach a beta `latest-mac.yml` to a stable tag).
171+
- Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration.
172172
- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
173173
- Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.)
174174
- Ship the DMG and tell users to install to `/Applications` — App Translocation breaks Squirrel.Mac updates from quarantined paths.

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

Lines changed: 138 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const autoUpdaterMock = {
88
allowDowngrade: false,
99
autoDownload: true,
1010
autoInstallOnAppQuit: false,
11+
autoRunAppAfterInstall: true,
1112
logger: null as unknown,
1213
on: vi.fn(),
1314
setFeedURL: vi.fn(),
@@ -25,6 +26,7 @@ import {
2526
isNewerVersion,
2627
parseSemver,
2728
resolveUpdateChannel,
29+
type UpdaterHandle,
2830
updateCheckIntervalMs,
2931
} from '@/main/updater'
3032

@@ -35,15 +37,20 @@ describe('resolveUpdateChannel', () => {
3537
})
3638

3739
it('maps prerelease versions to their channel', () => {
38-
expect(resolveUpdateChannel('1.2.3-beta.1')).toBe('beta')
39-
expect(resolveUpdateChannel('1.2.3-alpha.2')).toBe('alpha')
40+
expect(resolveUpdateChannel('1.2.3-dev.2')).toBe('dev')
41+
expect(resolveUpdateChannel('1.2.3-staging.1')).toBe('staging')
42+
})
43+
44+
it('keeps legacy alpha and beta builds on their environment streams', () => {
45+
expect(resolveUpdateChannel('1.2.3-alpha.2')).toBe('dev')
46+
expect(resolveUpdateChannel('1.2.3-beta.1')).toBe('staging')
4047
})
4148
})
4249

4350
describe('updateCheckIntervalMs', () => {
4451
it('checks dev and staging builds every five minutes', () => {
45-
expect(updateCheckIntervalMs('1.2.3-alpha.2')).toBe(5 * 60 * 1000)
46-
expect(updateCheckIntervalMs('1.2.3-beta.1')).toBe(5 * 60 * 1000)
52+
expect(updateCheckIntervalMs('1.2.3-dev.2')).toBe(5 * 60 * 1000)
53+
expect(updateCheckIntervalMs('1.2.3-staging.1')).toBe(5 * 60 * 1000)
4754
})
4855

4956
it('checks production builds every thirty minutes', () => {
@@ -61,6 +68,7 @@ describe('parseSemver', () => {
6168
it('returns null for garbage', () => {
6269
expect(parseSemver('latest')).toBeNull()
6370
expect(parseSemver('1.2')).toBeNull()
71+
expect(parseSemver('1.2.3garbage')).toBeNull()
6472
expect(parseSemver('')).toBeNull()
6573
})
6674
})
@@ -137,7 +145,11 @@ describe('initUpdater state machine', () => {
137145
}
138146
}
139147

140-
async function createUpdater(options?: { autoDownload?: boolean; feedAvailable?: boolean }) {
148+
async function createUpdater(options?: {
149+
autoDownload?: boolean
150+
feedAvailable?: boolean | 'no-release'
151+
probeOriginFeed?: (feedUrl: string) => Promise<boolean | 'no-release'>
152+
}) {
141153
const states: DesktopUpdateState[] = []
142154
const handle = initUpdater({
143155
getWindow: () => null,
@@ -147,7 +159,7 @@ describe('initUpdater state machine', () => {
147159
onStateChange: (state) => states.push(state),
148160
loadAutoUpdater: () =>
149161
autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'],
150-
probeOriginFeed: async () => options?.feedAvailable ?? false,
162+
probeOriginFeed: options?.probeOriginFeed ?? (async () => options?.feedAvailable ?? false),
151163
canSelfUpdate: async () => true,
152164
})
153165
// Engine selection (signature detection) resolves asynchronously.
@@ -162,7 +174,7 @@ describe('initUpdater state machine', () => {
162174
autoUpdaterMock.checkForUpdates.mockClear()
163175
autoUpdaterMock.downloadUpdate.mockClear()
164176
autoUpdaterMock.quitAndInstall.mockClear()
165-
// Keep the update-downloaded dialog from resolving into quitAndInstall.
177+
autoUpdaterMock.autoRunAppAfterInstall = false
166178
vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false })
167179
})
168180

@@ -188,12 +200,14 @@ describe('initUpdater state machine', () => {
188200
{ status: 'downloading', version: '2.0.0', percent: 42 },
189201
{ status: 'ready', version: '2.0.0' },
190202
])
203+
expect(dialog.showMessageBox).not.toHaveBeenCalled()
204+
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
191205

192206
handle.install()
193207
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
194208
})
195209

196-
it('stops at available and downloads on demand when auto-download is off', async () => {
210+
it('downloads, installs, and relaunches from one Update action', async () => {
197211
autoUpdaterMock.autoDownload = false
198212
const { handle } = await createUpdater({ autoDownload: false })
199213

@@ -203,6 +217,11 @@ describe('initUpdater state machine', () => {
203217
handle.check()
204218
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1)
205219
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
220+
221+
emit('update-downloaded', { version: '2.0.0' })
222+
expect(dialog.showMessageBox).not.toHaveBeenCalled()
223+
expect(autoUpdaterMock.autoRunAppAfterInstall).toBe(true)
224+
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
206225
})
207226

208227
it('checks from idle and ignores re-entrant checks while busy', async () => {
@@ -216,6 +235,31 @@ describe('initUpdater state machine', () => {
216235
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
217236
})
218237

238+
it('does not lose an interactive check while updater capability is initializing', async () => {
239+
let resolveCapability: ((capable: boolean) => void) | undefined
240+
const capability = new Promise<boolean>((resolve) => {
241+
resolveCapability = resolve
242+
})
243+
const states: DesktopUpdateState[] = []
244+
const handle = initUpdater({
245+
getWindow: () => null,
246+
events,
247+
appOrigin: () => 'https://www.dev.sim.ai',
248+
onStateChange: (state) => states.push(state),
249+
loadAutoUpdater: () =>
250+
autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'],
251+
probeOriginFeed: async () => true,
252+
canSelfUpdate: () => capability,
253+
})
254+
255+
handle.check()
256+
expect(states).toEqual([{ status: 'checking' }])
257+
258+
resolveCapability?.(true)
259+
await vi.advanceTimersByTimeAsync(0)
260+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
261+
})
262+
219263
it('resets to idle when a downloaded update is a blocked downgrade', async () => {
220264
const { handle } = await createUpdater()
221265
emit('update-downloaded', { version: '0.0.1' })
@@ -224,6 +268,17 @@ describe('initUpdater state machine', () => {
224268
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
225269
})
226270

271+
it('never exposes an equal, older, malformed, or cross-stream candidate as an update', async () => {
272+
const { handle } = await createUpdater()
273+
274+
for (const version of ['1.0.0', '0.9.9', 'nightly', '2.0.0-dev.1']) {
275+
emit('update-available', { version })
276+
expect(handle.getState()).toEqual({ status: 'idle' })
277+
}
278+
279+
expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled()
280+
})
281+
227282
it('surfaces updater errors and recovers via update-not-available', async () => {
228283
const { handle } = await createUpdater()
229284
emit('error', new Error('feed unreachable'))
@@ -233,7 +288,8 @@ describe('initUpdater state machine', () => {
233288
})
234289

235290
it('switches to the per-env origin feed when the origin serves one', async () => {
236-
await createUpdater({ feedAvailable: true })
291+
const { handle } = await createUpdater({ feedAvailable: true })
292+
handle.check()
237293
await vi.advanceTimersByTimeAsync(0)
238294
expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({
239295
provider: 'generic',
@@ -244,27 +300,70 @@ describe('initUpdater state machine', () => {
244300
})
245301

246302
it('keeps the packaged GitHub feed when the origin has no feed', async () => {
247-
await createUpdater({ feedAvailable: false })
303+
const { handle } = await createUpdater({ feedAvailable: false })
304+
handle.check()
248305
await vi.advanceTimersByTimeAsync(0)
249306
expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled()
250307
})
251308

252-
it('skips checks on prerelease builds when the origin feed is down', async () => {
309+
it('completes an interactive check immediately when the environment has no release', async () => {
310+
const { handle, states } = await createUpdater({ feedAvailable: 'no-release' })
311+
312+
handle.check()
313+
await vi.advanceTimersByTimeAsync(0)
314+
315+
expect(states).toEqual([{ status: 'checking' }, { status: 'idle' }])
316+
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
317+
})
318+
319+
it('re-probes a no-release environment so a newly published update appears without restart', async () => {
320+
const probeOriginFeed = vi
321+
.fn<(feedUrl: string) => Promise<boolean | 'no-release'>>()
322+
.mockResolvedValueOnce('no-release')
323+
.mockResolvedValueOnce(true)
324+
const { handle } = await createUpdater({ probeOriginFeed })
325+
326+
handle.check()
327+
await vi.advanceTimersByTimeAsync(0)
328+
expect(handle.getState()).toEqual({ status: 'idle' })
329+
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
330+
331+
handle.check()
332+
await vi.advanceTimersByTimeAsync(0)
333+
expect(probeOriginFeed).toHaveBeenCalledTimes(2)
334+
expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledTimes(1)
335+
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
336+
})
337+
338+
it('recovers an interactive check when the feed probe times out', async () => {
339+
const probeOriginFeed = vi.fn(() => new Promise<boolean>(() => {}))
340+
const { handle } = await createUpdater({ probeOriginFeed })
341+
342+
handle.check()
343+
expect(handle.getState()).toEqual({ status: 'checking' })
344+
await vi.advanceTimersByTimeAsync(10_000)
345+
346+
expect(handle.getState()).toEqual({ status: 'error' })
347+
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
348+
})
349+
350+
it('fails interactive checks promptly on prerelease builds when the origin feed is down', async () => {
253351
// The GitHub fallback is stable-only: a Sim Dev shell can never apply a
254352
// prod-identity artifact, so it must not check against it.
255-
vi.mocked(app.getVersion).mockReturnValue('1.0.1-alpha.7')
353+
vi.mocked(app.getVersion).mockReturnValue('1.0.1-dev.7')
256354
try {
257355
const { handle } = await createUpdater({ feedAvailable: false })
258356
handle.check()
259357
await vi.advanceTimersByTimeAsync(0)
260358
expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled()
359+
expect(handle.getState()).toEqual({ status: 'error' })
261360
} finally {
262361
vi.mocked(app.getVersion).mockReturnValue('1.0.0')
263362
}
264363
})
265364

266365
it('checks prerelease builds normally through the origin feed', async () => {
267-
vi.mocked(app.getVersion).mockReturnValue('1.0.1-alpha.7')
366+
vi.mocked(app.getVersion).mockReturnValue('1.0.1-dev.7')
268367
try {
269368
const { handle } = await createUpdater({ feedAvailable: true })
270369
handle.check()
@@ -276,8 +375,8 @@ describe('initUpdater state machine', () => {
276375
})
277376

278377
it.each([
279-
['1.0.1-alpha.7', 5 * 60 * 1000],
280-
['1.0.1-beta.7', 5 * 60 * 1000],
378+
['1.0.1-dev.7', 5 * 60 * 1000],
379+
['1.0.1-staging.7', 5 * 60 * 1000],
281380
['1.0.1', 30 * 60 * 1000],
282381
])('schedules %s update polling every %i milliseconds', async (version, interval) => {
283382
vi.mocked(app.getVersion).mockReturnValue(version)
@@ -505,11 +604,34 @@ describe('checkForUpdatesInteractive', () => {
505604
await vi.advanceTimersByTimeAsync(0)
506605

507606
expect(dialog.showMessageBox).toHaveBeenCalledWith(
508-
expect.objectContaining({ message: 'Sim is up to date' })
607+
expect.objectContaining({
608+
type: 'info',
609+
buttons: ['OK'],
610+
defaultId: 0,
611+
message: 'You’re up to date!',
612+
detail: `Sim ${app.getVersion()} is currently the newest version available.`,
613+
})
509614
)
510615
expect(shell.openExternal).not.toHaveBeenCalled()
511616
})
512617

618+
it('fails a hung interactive check after twelve seconds instead of waiting thirty', async () => {
619+
const handle: UpdaterHandle = {
620+
setAutoDownload: () => {},
621+
getState: () => ({ status: 'checking' }),
622+
check: vi.fn(),
623+
install: vi.fn(),
624+
onState: () => () => {},
625+
}
626+
627+
checkForUpdatesInteractive({ getWindow: () => null, events, handle })
628+
await vi.advanceTimersByTimeAsync(12_000)
629+
630+
expect(dialog.showMessageBox).toHaveBeenCalledWith(
631+
expect.objectContaining({ message: 'Could not check for updates' })
632+
)
633+
})
634+
513635
it('only explains packaged-build updates when unpackaged', async () => {
514636
;(app as unknown as { isPackaged: boolean }).isPackaged = false
515637
checkForUpdatesInteractive({ getWindow: () => null, events, handle: null })

0 commit comments

Comments
 (0)