Skip to content
Open
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
21 changes: 20 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1956,7 +1956,26 @@ session.
`fs.watch` of `<dir>/lib` that deliberately OUTLIVES the node's view; (5) the terminal is hidden
by default (`runConfig.showTerminal`, toggled by ⋯ with the extra-args field): collapse's
`display: none` path via `.term-node:has(.run-bar--compact)` and a node height fitted to the
rows; a run that ends without our Stop says where to look. No "bring simulator forward":
rows; a run that ends without our Stop says where to look; (6) **switching folders keeps the app
when it can** (`canHotSwitch`: a running Flutter run, same device, same flavor, Flutter on both
sides): the toolbar offers **Switch (keep app)** beside **Rebuild** — type `d` (flutter's own
detach: the tool exits, the app keeps running), `flutter attach` from the new folder (only the
flags attach accepts survive, `attachArgs`, MEASURED against `flutter attach --help` — attach has
no `--flavor`), wait until it prints its key help AFTER its own `▶ flutter attach` line
(`attachConnected`), then a hot RESTART — never a reload: a freshly attached tool only pushes
files changed after it connected (MEASURED live, Flutter 3.47 / iOS 27.1 simulator: a reload
after attaching from the other checkout "Reloaded 0 libraries" and the old code kept running; a
SIGUSR2 restart swapped it in 2.6 s, same app pid, no rebuild). **On an iOS simulator attach
needs `--debug-url`**: it finds an already-running app by mDNS, which the simulator barely
supports ("The Dart VM Service was not discovered after 30 seconds"), and the URL `flutter run`
printed is its DDS proxy, gone with the detached tool. `simulatorVmServiceUrl` reads the app's
own "The Dart VM service is listening on …" line from `simctl spawn <udid> log show` (~3 s) and
takes the newest one whose process is still alive (`parseVmServiceLog`); not found ⇒ the switch
is refused with Rebuild offered. Some Macs ship no registered `Simulator.app` (`open -a
Simulator` fails); booting falls back to the active Xcode's copy and otherwise runs headless. Flutter REFUSES a hand-passed `FLUTTER_APP_FLAVOR`, so after an
attach `appFlavor` is the pubspec default; an app whose `lib/` reads `appFlavor` is refused the
switch with that reason. Dart `"request": "attach"` configurations run as `flutter attach` too;
every other attach is still refused. No "bring simulator forward":
Simulator.app can only be activated as a whole (same-named simulators are told apart by id in
the dropdown). Add menus: **New view ▸ New run configuration**. Local projects only (disabled
with `RUN_SSH_HINT` in SSH projects; relay stub answers "managed on the host"); POSIX only
Expand Down
27 changes: 27 additions & 0 deletions src/core/run-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,33 @@ describe('run-service', () => {
expect(await startRun('../x', { projectDir: w, reloadOnSave: true })).toMatchObject({ ok: false })
})

it('switch mode writes a flutter attach launcher; refuses non-Flutter runs and apps that read appFlavor', async () => {
write('app/pubspec.yaml', PUBSPEC)
write('app/lib/main_dev.dart', 'void main() {}')
write('app/.vscode/launch.json', `{ "configurations": [
{ "name": "Dev", "type": "dart", "request": "launch", "program": "lib/main_dev.dart", "args": ["--flavor", "dev", "--web-port=8080"] },
{ "name": "Tool", "type": "node", "request": "launch", "program": "a.js" }
] }`)
const app = path.join(root, 'app')
const r = await startRun('n8', { projectDir: app, launchConfig: 'Dev', deviceId: 'SIM-1', reloadOnSave: true }, { attach: true })
expect(r).toMatchObject({ ok: true, kind: 'process', hotReload: true })
const script = readFileSync(launcherPath('n8'), 'utf8')
expect(script).toContain(`'flutter' 'attach' '-t' 'lib/main_dev.dart' '-d' 'SIM-1' '--pid-file' '${flutterPidFile('n8')}'`)
expect(script).not.toContain('--flavor')
expect(script).not.toContain('--web-port')

expect(await startRun('n8', { projectDir: app, launchConfig: 'Tool', reloadOnSave: true }, { attach: true })).toMatchObject({
ok: false,
error: expect.stringMatching(/not a Flutter run/)
})

write('app/lib/flavor.dart', "import 'package:flutter/services.dart'; final f = appFlavor;")
expect(await startRun('n8', { projectDir: app, launchConfig: 'Dev', deviceId: 'SIM-1', reloadOnSave: true }, { attach: true })).toEqual({
ok: false,
error: 'This app reads appFlavor, which flutter attach cannot set — rebuild to switch.'
})
})

it.skipIf(!posix)('runs the preLaunchTask, then the program with env + envFile, and records the exit code', async () => {
const w = path.join(root, 'w')
const out = path.join(root, 'out.txt')
Expand Down
125 changes: 119 additions & 6 deletions src/core/run-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
parseEnvFile,
parseLaunchFile,
parseTasksFile,
parseVmServiceLog,
planLaunch,
resolveEntry,
type LaunchConfig,
Expand Down Expand Up @@ -212,12 +213,63 @@ export async function bootSimulator(udid: unknown): Promise<boolean> {
if (!/current state: Booted/i.test(String((e as { stderr?: string }).stderr ?? (e as Error).message))) return false
}
devicesCache = null
await openSimulatorApp()
return true
}

/**
* Show Simulator.app, best effort. `open -a Simulator` needs LaunchServices to know the app, and on
* a machine where it does not (MEASURED: a Mac whose Xcode ships no registered Simulator.app —
* "Unable to find application named 'Simulator'") the bundle inside the active Xcode is tried. A
* machine with neither runs its simulators headless, which is fine: the app runs either way.
*/
async function openSimulatorApp(): Promise<void> {
try {
await run('/usr/bin/open', ['-a', 'Simulator'], { timeout: 15_000 })
return
} catch {
/* booted either way; the window is a convenience */
/* try the active Xcode's own copy */
}
return true
try {
const { stdout } = await run('/usr/bin/xcode-select', ['-p'], { timeout: 5_000 })
await run('/usr/bin/open', [path.join(stdout.trim(), 'Applications', 'Simulator.app')], { timeout: 15_000 })
} catch {
/* headless simulators — nothing to show */
}
}

/**
* The running Flutter app's own VM service URL on an iOS simulator — read from the simulator's
* log (see `parseVmServiceLog` for why attach needs it). The newest announcement whose process is
* still alive wins; an app that started long ago is found by widening the window once.
*/
export async function simulatorVmServiceUrl(udid: string): Promise<string | null> {
if (process.platform !== 'darwin' || !SIMULATOR_UDID.test(udid)) return null
let alive: Set<string> | null = null
try {
const { stdout } = await run('/usr/bin/xcrun', ['simctl', 'spawn', udid, 'launchctl', 'list'], { timeout: 15_000, maxBuffer: 8 * 1024 * 1024 })
alive = new Set(stdout.split('\n').map((l) => l.split('\t')[0]).filter((p) => /^\d+$/.test(p)))
} catch {
return null
}
for (const window of ['1h', '24h']) {
let text = ''
try {
text = (
await run(
'/usr/bin/xcrun',
['simctl', 'spawn', udid, 'log', 'show', '--style', 'compact', '--last', window, '--predicate', 'eventMessage CONTAINS "Dart VM service is listening on"'],
{ timeout: 90_000, maxBuffer: 16 * 1024 * 1024 }
)
).stdout
} catch {
continue
}
const hits = parseVmServiceLog(text).reverse()
const live = hits.find((h) => alive?.has(h.pid))
if (live) return live.url
}
return null
}

// ─── Project discovery ───────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -299,7 +351,43 @@ function referencedEnv(...values: unknown[]): string[] {
return names
}

export async function startRun(nodeId: unknown, rawConfig: unknown): Promise<RunStartResult> {
/**
* Does the app's own Dart code read `appFlavor`? `flutter attach` has no `--flavor`, and Flutter
* REFUSES a hand-passed `FLUTTER_APP_FLAVOR` define (flutter_command.dart,
* `_ensureReservedDartDefineIsUnset`), so after an attach + hot restart `appFlavor` falls back to
* the pubspec's default flavor — the wrong answer for an app that branches on it. Bounded scan of
* `lib/` (the app's code; packages are not the app's choice). A read failure answers "no".
*/
async function readsAppFlavor(dir: string): Promise<boolean> {
const stack = [path.join(dir, 'lib')]
let files = 0
while (stack.length && files < 4000) {
const d = stack.pop() as string
let entries: import('node:fs').Dirent[]
try {
entries = await readdir(d, { withFileTypes: true })
} catch {
continue
}
for (const e of entries) {
const p = path.join(d, e.name)
if (e.isDirectory()) stack.push(p)
else if (e.name.endsWith('.dart')) {
files++
try {
if ((await readFile(p, 'utf8')).includes('appFlavor')) return true
} catch {
/* unreadable — not evidence */
}
}
}
}
return false
}

export async function startRun(nodeId: unknown, rawConfig: unknown, rawOpts?: unknown): Promise<RunStartResult> {
// `attach`: switch a running Flutter app to this folder without rebuilding it (see canHotSwitch).
const attach = !!rawOpts && typeof rawOpts === 'object' && (rawOpts as { attach?: unknown }).attach === true
if (!validNode(nodeId)) return { ok: false, error: 'Invalid node.' }
if (process.platform === 'win32') {
return { ok: false, error: 'Run configurations need a POSIX shell — not supported on Windows yet.' }
Expand All @@ -326,7 +414,32 @@ export async function startRun(nodeId: unknown, rawConfig: unknown): Promise<Run
return { ok: false, error: `“${config.launchConfig}” is not in .vscode/launch.json.` }
}
if (!entry.supported) return { ok: false, error: entry.reason ?? 'This configuration cannot run here.' }
if (entry.kind === 'compound') return { ok: true, kind: 'compound', members: entry.members ?? [] }
if (entry.kind === 'compound') {
if (attach) return { ok: false, error: 'A compound cannot be switched to — rebuild instead.' }
return { ok: true, kind: 'compound', members: entry.members ?? [] }
}
let debugUrl: string | undefined
if (attach) {
if (!entry.hotReload || entry.typeLabel !== 'Flutter') {
return { ok: false, error: `“${entry.name}” is not a Flutter run — it cannot be switched to without a rebuild.` }
}
if (entry.flavor && (await readsAppFlavor(dir))) {
return {
ok: false,
error: 'This app reads appFlavor, which flutter attach cannot set — rebuild to switch.'
}
}
// On an iOS simulator attach cannot discover the running app by itself; hand it the URL.
if (config.deviceId && SIMULATOR_UDID.test(config.deviceId)) {
debugUrl = (await simulatorVmServiceUrl(config.deviceId)) ?? undefined
if (!debugUrl) {
return {
ok: false,
error: 'Could not find the running app on the simulator (no VM service URL in its log) — Rebuild to switch.'
}
}
}
}

const cfg = file.configs.find((c) => c.name === entry.name) as LaunchConfig
const tasks = await readTasks(dir)
Expand All @@ -344,7 +457,7 @@ export async function startRun(nodeId: unknown, rawConfig: unknown): Promise<Run
extraArgs: config.extraArgs,
flutterPidFile: flutterPidFile(nodeId),
tasks
})
}, { attach, debugUrl })
if (!planned.ok) return planned
const plan = planned.plan
if (plan.kind === 'browser') return { ok: true, kind: 'browser', url: plan.url }
Expand Down Expand Up @@ -528,7 +641,7 @@ export function registerRunConfigIpc(): void {
platform().handle(IPC.runDevices, (refresh: unknown) => listDevices(refresh === true))
platform().handle(IPC.runBootDevice, (udid: unknown) => bootSimulator(udid))
platform().handle(IPC.runDiscover, (dir: unknown) => discoverProjects(dir))
platform().handle(IPC.runStart, (nodeId: unknown, config: unknown) => startRun(nodeId, config))
platform().handle(IPC.runStart, (nodeId: unknown, config: unknown, opts: unknown) => startRun(nodeId, config, opts))
platform().handle(IPC.runStatus, (nodeId: unknown) => runStatus(nodeId))
platform().handle(IPC.runStop, (nodeId: unknown, force: unknown) => stopRun(nodeId, force === true))
platform().handle(IPC.runSignal, (nodeId: unknown, kind: unknown) => signalRun(nodeId, kind))
Expand Down
2 changes: 1 addition & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ const api: NodeTerminalApi = {
devices: (refresh) => ipcRenderer.invoke(IPC.runDevices, refresh),
bootDevice: (udid) => ipcRenderer.invoke(IPC.runBootDevice, udid),
discoverProjects: (dir) => ipcRenderer.invoke(IPC.runDiscover, dir),
start: (nodeId, config) => ipcRenderer.invoke(IPC.runStart, nodeId, config),
start: (nodeId, config, opts) => ipcRenderer.invoke(IPC.runStart, nodeId, config, opts),
status: (nodeId) => ipcRenderer.invoke(IPC.runStatus, nodeId),
stop: (nodeId, force) => ipcRenderer.invoke(IPC.runStop, nodeId, force),
signal: (nodeId, kind) => ipcRenderer.invoke(IPC.runSignal, nodeId, kind),
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/bridge/ws-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,7 +1104,7 @@ export function buildRunConfigApi(client: RpcClient): Pick<NodeTerminalApi, 'run
devices: (refresh) => client.request(IPC.runDevices, refresh) as Promise<RunDevicesResult>,
bootDevice: (udid) => client.request(IPC.runBootDevice, udid) as Promise<boolean>,
discoverProjects: (dir) => client.request(IPC.runDiscover, dir) as Promise<string[]>,
start: (nodeId, config) => client.request(IPC.runStart, nodeId, config) as Promise<RunStartResult>,
start: (nodeId, config, opts) => client.request(IPC.runStart, nodeId, config, opts) as Promise<RunStartResult>,
status: (nodeId) => client.request(IPC.runStatus, nodeId) as Promise<RunStatus>,
stop: (nodeId, force) => client.request(IPC.runStop, nodeId, force) as Promise<boolean>,
signal: (nodeId, kind) => client.request(IPC.runSignal, nodeId, kind) as Promise<boolean>,
Expand Down
Loading
Loading