Skip to content

Commit 96b74ee

Browse files
committed
Updates
1 parent 97ef2af commit 96b74ee

33 files changed

Lines changed: 973 additions & 111 deletions

File tree

‎apps/desktop/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +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+
"install:local": "bun run scripts/install-local.ts",
2122
"type-check": "tsc --noEmit",
2223
"lint": "biome check --write --unsafe .",
2324
"lint:check": "biome check .",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Local dev-install: packages the app from the current checkout and replaces
3+
* /Applications/Sim.app with it — the "run it like a real Mac app" loop
4+
* before official distribution. Signing/notarization are not involved; the
5+
* locally built app never carries a quarantine flag, so Gatekeeper doesn't
6+
* mind.
7+
*
8+
* bun run install:local # build → install → open (origin unchanged)
9+
* bun run install:local --local # …pointed at http://localhost:3000
10+
* bun run install:local --dev # …pointed at https://www.dev.sim.ai
11+
* bun run install:local --staging # …pointed at https://www.staging.sim.ai
12+
* bun run install:local --prod # …pointed at https://www.sim.ai
13+
* bun run install:local --no-open # build → install only
14+
*
15+
* The origin flag writes the app's persisted settings (same as changing the
16+
* server URL in Settings), so it survives relaunches; each origin keeps its
17+
* own isolated session partition. A running installed copy is quit before
18+
* replacing. Note the installed app and `bun run dev` share the same profile
19+
* and single-instance lock, so only one can run at a time.
20+
*/
21+
import { execFileSync, spawnSync } from 'node:child_process'
22+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
23+
import { homedir } from 'node:os'
24+
import { dirname, join } from 'node:path'
25+
26+
const APP_NAME = 'Sim.app'
27+
const INSTALL_PATH = `/Applications/${APP_NAME}`
28+
const RELEASE_DIRS = ['release/mac-universal', 'release/mac-arm64', 'release/mac']
29+
/** Matches the app's userData path (app.setName('Sim') in src/main/index.ts). */
30+
const SETTINGS_PATH = join(homedir(), 'Library/Application Support/Sim/settings.json')
31+
32+
const ORIGIN_FLAGS: Record<string, string> = {
33+
'--local': 'http://localhost:3000',
34+
'--dev': 'https://www.dev.sim.ai',
35+
'--staging': 'https://www.staging.sim.ai',
36+
'--prod': 'https://www.sim.ai',
37+
}
38+
39+
function run(command: string, args: string[]): void {
40+
const result = spawnSync(command, args, { stdio: 'inherit' })
41+
if (result.status !== 0) {
42+
console.error(`\n✖ ${command} ${args.join(' ')} failed`)
43+
process.exit(result.status ?? 1)
44+
}
45+
}
46+
47+
function localBuildStamp(): string {
48+
try {
49+
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD']).toString().trim()
50+
const dirty = execFileSync('git', ['status', '--porcelain']).toString().trim() ? '+dirty' : ''
51+
return `${sha}${dirty}`
52+
} catch {
53+
return 'unknown'
54+
}
55+
}
56+
57+
function quitInstalledApp(): void {
58+
// Match only processes launched from the installed bundle — never the dev
59+
// instance running out of node_modules/electron.
60+
const running = spawnSync('pgrep', ['-f', `${INSTALL_PATH}/Contents/MacOS/`]).status === 0
61+
if (!running) return
62+
console.log('• Quitting the running installed app…')
63+
spawnSync('osascript', ['-e', 'tell application "Sim" to quit'])
64+
// Poll briefly; fall back to a hard kill so the install never half-replaces
65+
// a live bundle.
66+
for (let i = 0; i < 20; i++) {
67+
if (spawnSync('pgrep', ['-f', `${INSTALL_PATH}/Contents/MacOS/`]).status !== 0) return
68+
execFileSync('sleep', ['0.25'])
69+
}
70+
spawnSync('pkill', ['-f', `${INSTALL_PATH}/Contents/MacOS/`])
71+
}
72+
73+
/**
74+
* Points the installed app at an environment by writing its persisted
75+
* settings — the same field the in-app Settings window edits. Merges into the
76+
* existing file so window bounds, shortcuts, etc. survive.
77+
*/
78+
function applyOrigin(origin: string): void {
79+
let settings: Record<string, unknown> = {}
80+
try {
81+
settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')) as Record<string, unknown>
82+
} catch {
83+
// Missing or corrupt settings file — start fresh; the app validates on load.
84+
}
85+
settings.origin = origin
86+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
87+
writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`)
88+
console.log(`• Server origin set to ${origin}`)
89+
if (origin.startsWith('http://localhost')) {
90+
console.log(' (make sure the sim dev server is running on :3000)')
91+
}
92+
}
93+
94+
const originFlags = process.argv.filter((arg) => arg in ORIGIN_FLAGS)
95+
if (originFlags.length > 1) {
96+
console.error(`✖ Pass at most one of ${Object.keys(ORIGIN_FLAGS).join(', ')}`)
97+
process.exit(1)
98+
}
99+
100+
console.log('• Packaging the app from the current checkout…')
101+
run('bun', ['run', 'package:dir'])
102+
103+
const builtApp = RELEASE_DIRS.map((dir) => join(dir, APP_NAME)).find(existsSync)
104+
if (!builtApp) {
105+
console.error(`✖ No built app found under ${RELEASE_DIRS.join(', ')}`)
106+
process.exit(1)
107+
}
108+
109+
quitInstalledApp()
110+
111+
console.log(`• Installing ${builtApp} → ${INSTALL_PATH}`)
112+
rmSync(INSTALL_PATH, { recursive: true, force: true })
113+
// ditto preserves the code signature and extended attributes, unlike cp.
114+
run('ditto', [builtApp, INSTALL_PATH])
115+
116+
if (originFlags.length === 1) {
117+
applyOrigin(ORIGIN_FLAGS[originFlags[0]])
118+
}
119+
120+
console.log(`✔ Installed Sim (${localBuildStamp()}) to ${INSTALL_PATH}`)
121+
122+
if (!process.argv.includes('--no-open')) {
123+
run('open', [INSTALL_PATH])
124+
} else {
125+
console.log(` Launch it with: open ${INSTALL_PATH}`)
126+
}

‎apps/desktop/src/main/browser-agent/driver.ts‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -569,19 +569,29 @@ export async function executeTool(
569569
): Promise<{ ok: boolean; result?: unknown; error?: string }> {
570570
const run = async () => {
571571
logger.info('Executing browser tool', { tool })
572-
const execution = executeToolInner(tool, params)
573-
const raced =
574-
tool === 'browser_request_takeover'
575-
? execution
576-
: Promise.race([
577-
execution,
578-
sleep(TOOL_WATCHDOG_MS).then(() => {
579-
throw new ToolError(
580-
'The browser did not finish this action in time. Take a browser_snapshot to see the current page state.'
581-
)
582-
}),
583-
])
584-
return withNotices(await raced)
572+
const keepHiddenPageActive = tool !== 'browser_request_takeover'
573+
if (keepHiddenPageActive) {
574+
session.setAutomationActive(true)
575+
}
576+
try {
577+
const execution = executeToolInner(tool, params)
578+
const raced =
579+
tool === 'browser_request_takeover'
580+
? execution
581+
: Promise.race([
582+
execution,
583+
sleep(TOOL_WATCHDOG_MS).then(() => {
584+
throw new ToolError(
585+
'The browser did not finish this action in time. Take a browser_snapshot to see the current page state.'
586+
)
587+
}),
588+
])
589+
return withNotices(await raced)
590+
} finally {
591+
if (keepHiddenPageActive) {
592+
session.setAutomationActive(false)
593+
}
594+
}
585595
}
586596

587597
const settled = toolQueue.then(run, run)

‎apps/desktop/src/main/browser-agent/session.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface MockView {
1414
}
1515
setWindowOpenHandler: ReturnType<typeof vi.fn>
1616
loadURL: ReturnType<typeof vi.fn>
17+
setBackgroundThrottling: ReturnType<typeof vi.fn>
1718
}
1819
setBounds: ReturnType<typeof vi.fn>
1920
setVisible: ReturnType<typeof vi.fn>
@@ -64,6 +65,17 @@ describe('browser-agent session', () => {
6465
expect(session.listTabs()[0]).toMatchObject({ tabId: first.id, active: true })
6566
})
6667

68+
it('only disables hidden-page throttling while browser automation is active', () => {
69+
const tab = session.ensureTab()
70+
const contents = (tab.view as unknown as MockView).webContents
71+
72+
session.setAutomationActive(true)
73+
expect(contents.setBackgroundThrottling).toHaveBeenLastCalledWith(false)
74+
75+
session.setAutomationActive(false)
76+
expect(contents.setBackgroundThrottling).toHaveBeenLastCalledWith(true)
77+
})
78+
6779
it('requireTab refuses when no page is open yet', () => {
6880
expect(() => session.requireTab()).toThrow(/No page is open yet/)
6981
})

‎apps/desktop/src/main/browser-agent/session.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ let getMainWindow: () => BrowserWindow | null = () => null
4747
let panelBounds: BrowserPanelBounds | null = null
4848
let panelLeaseAt = 0
4949
let leaseTimer: ReturnType<typeof setInterval> | null = null
50+
/** Prevent hidden-page throttling only while an agent action needs the page to make progress. */
51+
let automationActive = false
5052
/** The window currently hosting the active view, for re-parenting checks. */
5153
let hostedWindow: BrowserWindow | null = null
5254

@@ -86,9 +88,9 @@ function createTabView(): WebContentsView {
8688
sandbox: true,
8789
webSecurity: true,
8890
webviewTag: false,
89-
// Pages keep running (timers, fetches) while the panel is hidden or
90-
// covered — automation must not depend on panel visibility.
91-
backgroundThrottling: false,
91+
// Visible pages remain full speed. Hidden pages may be throttled unless
92+
// a browser tool is actively waiting on them.
93+
backgroundThrottling: !automationActive,
9294
spellcheck: false,
9395
},
9496
})
@@ -120,6 +122,19 @@ export function hasSession(): boolean {
120122
return tabs.some((tab) => !tab.view.webContents.isDestroyed())
121123
}
122124

125+
/**
126+
* Keeps hidden pages responsive during an agent action, then returns them to
127+
* Chromium's normal background throttling so they cannot contend with Sim.
128+
*/
129+
export function setAutomationActive(active: boolean): void {
130+
automationActive = active
131+
for (const tab of tabs) {
132+
if (!tab.view.webContents.isDestroyed()) {
133+
tab.view.webContents.setBackgroundThrottling(!active)
134+
}
135+
}
136+
}
137+
123138
/** The view currently attached to the host window (attach only on change —
124139
* re-adding an attached view re-stacks it and can flicker the composite). */
125140
let attachedView: WebContentsView | null = null

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export interface DesktopSettings {
2626
/** Quick Ask launcher accelerator — one of the presets in shortcuts.ts, or 'disabled'. */
2727
launcherShortcut?: string
2828
trayEnabled?: boolean
29+
notificationsEnabled?: boolean
30+
notificationSounds?: boolean
31+
notificationsOnlyWhenUnfocused?: boolean
32+
launchAtLogin?: boolean
33+
autoDownloadUpdates?: boolean
2934
}
3035

3136
export type OriginValidation = { ok: true; origin: string } | { ok: false; error: string }
@@ -91,6 +96,11 @@ export function isSafeInternalPath(path: unknown): path is string {
9196
const DEFAULT_SETTINGS: DesktopSettings = {
9297
origin: DEFAULT_ORIGIN,
9398
blockThirdPartyAnalytics: true,
99+
notificationsEnabled: true,
100+
notificationSounds: true,
101+
notificationsOnlyWhenUnfocused: true,
102+
launchAtLogin: false,
103+
autoDownloadUpdates: true,
94104
}
95105

96106
export interface ConfigStore {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type {
2+
DesktopNotificationPayload,
3+
DesktopPreferenceKey,
4+
DesktopPreferences,
5+
} from '@sim/desktop-bridge'
6+
import type { BrowserWindow } from 'electron'
7+
import { app, Notification } from 'electron'
8+
import type { ConfigStore } from '@/main/config'
9+
import { isSafeInternalPath } from '@/main/config'
10+
11+
const PREFERENCE_KEYS: ReadonlySet<string> = new Set<DesktopPreferenceKey>([
12+
'notificationsEnabled',
13+
'notificationSounds',
14+
'notificationsOnlyWhenUnfocused',
15+
'launchAtLogin',
16+
'autoDownloadUpdates',
17+
])
18+
19+
export function isDesktopPreferenceKey(value: unknown): value is DesktopPreferenceKey {
20+
return typeof value === 'string' && PREFERENCE_KEYS.has(value)
21+
}
22+
23+
export interface DesktopSettingsService {
24+
getPreferences(): DesktopPreferences
25+
setPreference(key: DesktopPreferenceKey, value: boolean): DesktopPreferences
26+
notify(payload: DesktopNotificationPayload): boolean
27+
applySystemPreferences(): void
28+
}
29+
30+
interface DesktopSettingsServiceDeps {
31+
config: ConfigStore
32+
getMainWindow: () => BrowserWindow | null
33+
openMainWindowAt: (route?: string) => void
34+
setAutoDownloadUpdates: (enabled: boolean) => void
35+
}
36+
37+
function readPreferences(config: ConfigStore): DesktopPreferences {
38+
return {
39+
notificationsEnabled: config.get('notificationsEnabled') ?? true,
40+
notificationSounds: config.get('notificationSounds') ?? true,
41+
notificationsOnlyWhenUnfocused: config.get('notificationsOnlyWhenUnfocused') ?? true,
42+
launchAtLogin: config.get('launchAtLogin') ?? false,
43+
autoDownloadUpdates: config.get('autoDownloadUpdates') ?? true,
44+
}
45+
}
46+
47+
/**
48+
* Owns device preferences and their native side effects. Renderer code can
49+
* request a change, but only this main-process service touches login items,
50+
* updater policy, window focus, or OS notifications.
51+
*/
52+
export function createDesktopSettingsService(
53+
deps: DesktopSettingsServiceDeps
54+
): DesktopSettingsService {
55+
const applyLaunchAtLogin = (enabled: boolean) => {
56+
// Registering an unpackaged Electron binary at login is surprising and
57+
// points at the wrong executable. Persist the dev preference, then apply
58+
// it when the packaged app starts.
59+
if (app.isPackaged) {
60+
app.setLoginItemSettings({ openAtLogin: enabled })
61+
}
62+
}
63+
64+
return {
65+
getPreferences: () => readPreferences(deps.config),
66+
setPreference(key, value) {
67+
deps.config.set(key, value)
68+
if (key === 'launchAtLogin') {
69+
applyLaunchAtLogin(value)
70+
} else if (key === 'autoDownloadUpdates') {
71+
deps.setAutoDownloadUpdates(value)
72+
}
73+
return readPreferences(deps.config)
74+
},
75+
notify(payload) {
76+
const preferences = readPreferences(deps.config)
77+
if (!preferences.notificationsEnabled || !Notification.isSupported()) {
78+
return false
79+
}
80+
const window = deps.getMainWindow()
81+
if (preferences.notificationsOnlyWhenUnfocused && window?.isFocused()) {
82+
return false
83+
}
84+
85+
const notification = new Notification({
86+
title: payload.title,
87+
body: payload.body,
88+
silent: !preferences.notificationSounds,
89+
})
90+
notification.on('click', () => {
91+
deps.openMainWindowAt(
92+
payload.route && isSafeInternalPath(payload.route) ? payload.route : undefined
93+
)
94+
})
95+
notification.show()
96+
return true
97+
},
98+
applySystemPreferences() {
99+
const preferences = readPreferences(deps.config)
100+
applyLaunchAtLogin(preferences.launchAtLogin)
101+
deps.setAutoDownloadUpdates(preferences.autoDownloadUpdates)
102+
},
103+
}
104+
}

0 commit comments

Comments
 (0)