Skip to content

Commit fd37d12

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/presend-email-verify
# Conflicts: # apps/sim/tools/generated/tool-ids.ts # apps/sim/tools/generated/tool-metadata.ts # apps/sim/tools/generated/tool-outputs.ts
2 parents cadf3ba + 8725250 commit fd37d12

339 files changed

Lines changed: 162827 additions & 2350 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/memory-load-check/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Read these when doing a deeper pass:
4545
- dispatch concrete chunks (`workspaceIds`, retention, label) instead of one giant scope
4646
- prefer Trigger.dev queue/concurrency keys when available
4747
- execute inline fallback chunks sequentially, not with unbounded `Promise.all`
48-
- File parse pattern in `apps/sim/lib/internal/file/parser.ts` and `apps/sim/lib/uploads/contexts/workspace/fetch-external-url.ts`
48+
- File parse pattern in `apps/sim/lib/internal/file/parser.ts` and `apps/sim/lib/uploads/utils/fetch-external-url.server.ts`
4949
- cap downloads and parsed output separately
5050
- preserve partial results when a later item exceeds the cap
5151
- never read untrusted response bodies without a byte cap

‎apps/desktop/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
194194

195195
- `electron-updater` reads the deployment's `/api/desktop/update` feed; production resolves stable releases from `simstudioai/sim`, while dev/staging resolve prereleases from `simstudioai/sim-desktop-releases`. Artifact downloads go directly to GitHub and deltas use `.zip.blockmap`. Sim validates every candidate before starting its download. Developer ID builds installed under `/Applications` use a prompt (Restart and update / Later; Later installs on quit); other packaged builds offer a validated installer download — never forced mid-session. A staged or offered update keeps being re-checked on the normal cadence, and a newer release replaces it, so a shell left running across several releases installs the latest build in one restart instead of the stale one followed by another prompt.
196196
- 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.
197+
- Restart becomes available only after Squirrel confirms native staging. Replacing a staged update returns the UI to downloading; a failed transfer can retain the previous staged build, but a failure after the native feed is replaced requires a retry. Diagnostics record `update_downloaded` after native staging, `update_install` when an explicit restart is committed, and `update_install_result` on the next launch with the expected and installed versions. The staging checkpoint also covers updates installed on a normal quit.
198+
- `bun run test:e2e e2e/updater.spec.ts` exercises the real Electron process, MacUpdater, downloads, retries, and installation checkpoints. Native verification and bundle replacement are simulated. Set `DESKTOP_UPDATER_REPORT_PATH` to choose the JSON report path; by default it is included in Playwright's test results and uploaded by CI on failure.
197199
- Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean.
198200
- 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.)
199201
- Ship the DMG and tell users to install to `/Applications` — App Translocation breaks Squirrel.Mac updates from quarantined paths.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { writeFileSync } from 'node:fs'
2+
import { homedir } from 'node:os'
3+
import { join, relative } from 'node:path'
4+
import type { DesktopUpdateState } from '@sim/desktop-bridge'
5+
import { app, autoUpdater as nativeUpdater, net } from 'electron'
6+
import { MacUpdater } from 'electron-updater'
7+
import { initUpdater } from '@/main/updater'
8+
9+
declare global {
10+
var desktopUpdaterFixture: {
11+
check(): void
12+
install(): void
13+
finishStaging(): void
14+
failStaging(): void
15+
read(): {
16+
state: DesktopUpdateState
17+
nativeArchive: string | null
18+
installed: string[]
19+
events: { name: string; data: unknown }[]
20+
}
21+
}
22+
}
23+
24+
const directory = process.env.SIM_UPDATER_FIXTURE_DIR
25+
const origin = process.env.SIM_UPDATER_FIXTURE_ORIGIN
26+
if (!directory || !origin) throw new Error('Updater fixture configuration is missing')
27+
app.setPath('userData', join(directory, 'user-data'))
28+
29+
void app.whenReady().then(() => {
30+
const configPath = join(directory, 'updater.yml')
31+
const cache = relative(join(homedir(), 'Library', 'Caches'), join(directory, 'cache'))
32+
writeFileSync(configPath, `updaterCacheDirName: ${JSON.stringify(cache)}\n`)
33+
34+
let feed: Electron.FeedURLOptions | undefined
35+
let nativeArchive: string | null = null
36+
const installed: string[] = []
37+
const events: { name: string; data: unknown }[] = []
38+
39+
/** Native verification and installation are simulated; MacUpdater and both HTTP transfers run. */
40+
nativeUpdater.setFeedURL = (options) => {
41+
feed = options
42+
nativeArchive = null
43+
}
44+
nativeUpdater.checkForUpdates = () => {
45+
void (async () => {
46+
if (!feed) throw new Error('Native feed was not configured')
47+
const response = await net.fetch(feed.url, { headers: feed.headers })
48+
const manifest: { url: string } = await response.json()
49+
const archive = await net.fetch(manifest.url)
50+
nativeArchive = await archive.text()
51+
})().catch((error) => nativeUpdater.emit('error', error))
52+
}
53+
nativeUpdater.quitAndInstall = () => {
54+
if (nativeArchive === null) throw new Error('Cannot install before native staging')
55+
installed.push(nativeArchive)
56+
}
57+
58+
const updater = new MacUpdater()
59+
updater.forceDevUpdateConfig = true
60+
updater.disableDifferentialDownload = true
61+
updater.updateConfigPath = configPath
62+
updater.setFeedURL({ provider: 'generic', url: origin })
63+
const handle = initUpdater({
64+
getWindow: () => null,
65+
events: { filePath: '', record: (name, data) => events.push({ name, data }) },
66+
appOrigin: () => origin,
67+
loadAutoUpdater: () => updater,
68+
canSelfUpdate: async () => true,
69+
probeOriginFeed: async () => false,
70+
installStatePath: join(directory, 'update-install.json'),
71+
})
72+
73+
globalThis.desktopUpdaterFixture = {
74+
check: () => handle.check(),
75+
install: () => handle.install(),
76+
finishStaging: () => {
77+
if (nativeArchive === null) throw new Error('Native transfer has not finished')
78+
nativeUpdater.emit('update-downloaded', {}, '', nativeArchive, new Date(), '')
79+
},
80+
failStaging: () => nativeUpdater.emit('error', new Error('Native verification failed')),
81+
read: () => ({ state: handle.getState(), nativeArchive, installed, events }),
82+
}
83+
})

‎apps/desktop/e2e/updater.spec.ts‎

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { createHash } from 'node:crypto'
2+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3+
import { createServer } from 'node:http'
4+
import { tmpdir } from 'node:os'
5+
import { dirname, join } from 'node:path'
6+
import { fileURLToPath } from 'node:url'
7+
import { _electron as electron, expect, test } from '@playwright/test'
8+
import { getErrorMessage } from '@sim/utils/errors'
9+
import { build } from 'esbuild'
10+
11+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
12+
13+
test('the real MacUpdater waits for native staging, replaces old builds, and retries failed staging', async () => {
14+
test.skip(process.platform !== 'darwin', 'Squirrel.Mac lifecycle')
15+
const directory = mkdtempSync(join(tmpdir(), 'sim-updater-e2e-'))
16+
const reportPath =
17+
process.env.DESKTOP_UPDATER_REPORT_PATH ?? test.info().outputPath('updater.json')
18+
let app: Awaited<ReturnType<typeof electron.launch>> | undefined
19+
let offeredVersion = '2.0.0'
20+
const requests: { path: string; status: number }[] = []
21+
const checks: {
22+
name: string
23+
status: 'passed' | 'failed'
24+
durationMs: number
25+
error?: string
26+
}[] = []
27+
const check = async (name: string, run: () => Promise<void>) => {
28+
const started = Date.now()
29+
try {
30+
await run()
31+
checks.push({ name, status: 'passed', durationMs: Date.now() - started })
32+
} catch (error) {
33+
checks.push({
34+
name,
35+
status: 'failed',
36+
durationMs: Date.now() - started,
37+
error: getErrorMessage(error),
38+
})
39+
throw error
40+
}
41+
}
42+
let snapshot: unknown
43+
const server = createServer((request, response) => {
44+
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
45+
requests.push({ path, status: 200 })
46+
if (path === '/latest-mac.yml') {
47+
const archive = Buffer.from(offeredVersion)
48+
response.end(
49+
`version: ${offeredVersion}\nfiles:\n - url: Sim-${offeredVersion}-universal.zip\n sha512: ${createHash('sha512').update(archive).digest('base64')}\n size: ${archive.length}\n`
50+
)
51+
} else {
52+
response.end(/^\/Sim-(.+)-universal.zip$/.exec(path)?.[1] ?? '')
53+
}
54+
})
55+
56+
try {
57+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
58+
const address = server.address()
59+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
60+
mkdirSync(join(directory, 'user-data'))
61+
writeFileSync(
62+
join(directory, 'package.json'),
63+
JSON.stringify({ name: 'sim-updater-fixture', version: '1.0.0', main: 'main.cjs' })
64+
)
65+
await build({
66+
entryPoints: [join(DESKTOP_DIR, 'e2e/fixtures/updater.ts')],
67+
outfile: join(directory, 'main.cjs'),
68+
bundle: true,
69+
platform: 'node',
70+
format: 'cjs',
71+
external: ['electron'],
72+
tsconfig: join(DESKTOP_DIR, 'tsconfig.json'),
73+
plugins: [
74+
{
75+
name: 'approve-fixture-restart',
76+
setup(builder) {
77+
builder.onResolve({ filter: /^@\/main\/dialogs$/ }, () => ({
78+
path: 'dialog',
79+
namespace: 'fixture',
80+
}))
81+
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({
82+
contents:
83+
'export async function showShellDialog() { return { response: 1, checkboxChecked: false } }',
84+
}))
85+
},
86+
},
87+
],
88+
})
89+
app = await electron.launch({
90+
args: [directory, '--use-mock-keychain'],
91+
env: {
92+
...process.env,
93+
SIM_UPDATER_FIXTURE_DIR: directory,
94+
SIM_UPDATER_FIXTURE_ORIGIN: `http://127.0.0.1:${address.port}`,
95+
},
96+
})
97+
const shell = app
98+
const read = () => shell.evaluate(() => globalThis.desktopUpdaterFixture.read())
99+
await expect
100+
.poll(() => shell.evaluate(() => Boolean(globalThis.desktopUpdaterFixture)))
101+
.toBe(true)
102+
103+
await check('first download waits for native staging', async () => {
104+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.check())
105+
await expect.poll(async () => (await read()).nativeArchive).toBe('2.0.0')
106+
expect((await read()).state).toEqual({
107+
status: 'downloading',
108+
version: '2.0.0',
109+
percent: 100,
110+
})
111+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.install())
112+
expect((await read()).installed).toEqual([])
113+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.finishStaging())
114+
expect((await read()).state).toEqual({ status: 'ready', version: '2.0.0' })
115+
})
116+
117+
await check('replacement cannot restart into stale native update', async () => {
118+
offeredVersion = '2.1.0'
119+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.check())
120+
await expect.poll(async () => (await read()).nativeArchive).toBe('2.1.0')
121+
expect((await read()).state).toEqual({
122+
status: 'downloading',
123+
version: '2.1.0',
124+
percent: 100,
125+
})
126+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.install())
127+
expect((await read()).installed).toEqual([])
128+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.failStaging())
129+
expect((await read()).state).toEqual({ status: 'error', version: '2.1.0' })
130+
})
131+
132+
await check('cached retry installs only the verified replacement', async () => {
133+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.check())
134+
await expect
135+
.poll(async () => (await read()).state)
136+
.toEqual({ status: 'downloading', version: '2.1.0', percent: 100 })
137+
await expect.poll(async () => (await read()).nativeArchive).toBe('2.1.0')
138+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.finishStaging())
139+
expect((await read()).state).toEqual({ status: 'ready', version: '2.1.0' })
140+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.install())
141+
await expect.poll(async () => (await read()).installed).toEqual(['2.1.0'])
142+
})
143+
snapshot = await read()
144+
await check(
145+
'the next process distinguishes an incomplete update from a successful install',
146+
async () => {
147+
const checkpoint = readFileSync(join(directory, 'update-install.json'), 'utf8')
148+
await app?.close()
149+
app = await electron.launch({
150+
args: [directory, '--use-mock-keychain'],
151+
env: {
152+
...process.env,
153+
SIM_UPDATER_FIXTURE_DIR: directory,
154+
SIM_UPDATER_FIXTURE_ORIGIN: `http://127.0.0.1:${address.port}`,
155+
},
156+
})
157+
await expect
158+
.poll(() => app?.evaluate(() => globalThis.desktopUpdaterFixture?.read().events))
159+
.toContainEqual({
160+
name: 'update_install_result',
161+
data: { expected: '2.1.0', installed: '1.0.0', success: false },
162+
})
163+
await app.close()
164+
writeFileSync(join(directory, 'update-install.json'), checkpoint)
165+
writeFileSync(
166+
join(directory, 'package.json'),
167+
JSON.stringify({ name: 'sim-updater-fixture', version: '2.1.0', main: 'main.cjs' })
168+
)
169+
app = await electron.launch({
170+
args: [directory, '--use-mock-keychain'],
171+
env: {
172+
...process.env,
173+
SIM_UPDATER_FIXTURE_DIR: directory,
174+
SIM_UPDATER_FIXTURE_ORIGIN: `http://127.0.0.1:${address.port}`,
175+
},
176+
})
177+
await expect
178+
.poll(() => app?.evaluate(() => globalThis.desktopUpdaterFixture?.read().events))
179+
.toContainEqual({
180+
name: 'update_install_result',
181+
data: { expected: '2.1.0', installed: '2.1.0', success: true },
182+
})
183+
}
184+
)
185+
} finally {
186+
if (!snapshot && app)
187+
snapshot = await app
188+
.evaluate(() => globalThis.desktopUpdaterFixture?.read())
189+
.catch(() => undefined)
190+
mkdirSync(dirname(reportPath), { recursive: true })
191+
writeFileSync(
192+
reportPath,
193+
JSON.stringify(
194+
{
195+
passed: checks.length === 4 && checks.every((check) => check.status === 'passed'),
196+
checks,
197+
requests,
198+
snapshot,
199+
},
200+
null,
201+
2
202+
)
203+
)
204+
await app?.close()
205+
server.closeAllConnections()
206+
await new Promise<void>((resolve) => server.close(() => resolve()))
207+
rmSync(directory, { recursive: true, force: true })
208+
}
209+
})

‎apps/desktop/src/main/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,7 @@ function main(): void {
866866
updater = initUpdater({
867867
getWindow: getMainWindow,
868868
events,
869+
installStatePath: join(userDataPath, 'update-install.json'),
869870
appOrigin,
870871
autoDownload: () => config.get('autoDownloadUpdates') ?? true,
871872
setRelaunchPending: (pending) => {

‎apps/desktop/src/main/observability.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type DesktopEventName =
3333
| 'update_check'
3434
| 'update_feed'
3535
| 'update_downloaded'
36+
| 'update_install'
37+
| 'update_install_result'
3638
| 'update_error'
3739
| 'update_blocked_version'
3840
| 'update_manual_mode'

0 commit comments

Comments
 (0)