macOS Flutter IDE for Serverpod 4.1 projects: open a project, start or attach to its Serverpod runner, show the runner's structured log natively, and preview the Flutter web app in CEF at the URL the runner publishes.
lib/
main.dart # ShadApp, wires controllers
agent/ # OpenCode serve client, SSE, AgentController, transcript
ui/ # studio_shell, preview_pane, left_pane, server_log/, agent/, settings/
runner/ # Serverpod runner: protocol, client, discovery, log mirror, ownership
preview/ # CEF preview: device modes, theme shim, surface sync
process/ # ServerpodProcessController (runner lifecycle, actions), ProjectCreator
session/ # ProjectSession (open project, recents)
theme/ # Shad themes, layout constants, ThemeController
toolchain/ # Probe + install CLT, Flutter, Serverpod CLI (official sources)
widgets/ # Shared UI (IconSegmentedControl, MenuItemTile)
test_driver/ # Driver-enabled entry points for the Dart MCP server
widgetbook/ # `make widgetbook`: catalog, host, fakes (see Widgetbook)
packages/
mac_menu_bar/ # Local fork — SF Symbol icons, separators, reset()
webview_cef/ # Local fork — CEF resize + skip Helper (Alerts)
tool/
distribute_macos.sh, publish_*.sh, set_version.sh # releasing the app
UI reference for design patterns: ../cloudui/cloudui_flutter/.
kOpenCodeAgentEnabled in lib/feature_flags.dart is false, and everything
in this section is dormant behind it. The code is all still here; the flag is
the whole switch, and flipping it to true brings the feature back intact. Read
the rest of this section as what happens when it is on.
OpenCode is no longer a tool App Studio installs, whatever the flag says —
it is not in the manifest, not probed and has no Settings → Tools row. With the
flag on, OpenCodeServe runs whichever opencode is on the user's PATH, and
reports it missing otherwise.
Off, three things step back, and each reads the flag directly:
IdeIntegration.opencode.builtInbecomesfalse, soserverpod createis no longer told to configure it on every new project. See the New projects section:_neverInferredinproject_settings.darthad to stop keying offbuiltInfor this, because the projects that already carry anopencode.jsonare still on disk.- The left panel drops its Agent tab and shows the server log alone, and Settings drops its Providers tab — which configures keys for this agent and nothing else, so one pane is left and the tab strip goes with it. The dialog coerces a caller asking for Providers onto Tools: the App menu still asks, and that pane's "waiting for the agent" spinner would never resolve.
AgentControllernever listens for a project, so noopencode serveis ever spawned. It is still constructed, still handed to everything expecting one, and still stopped byteardownSession.
Tests that cover the switched-on behaviour are skipped, not deleted —
test/support/agent_flag.dart carries skipWhenAgentOff / skipWhenAgentOn.
- Agent tab hosts a Cursor-style chat driven by
opencode serve(HTTP + SSE). - Scoped to the open project directory; restart serve on project switch.
opencode servestarts as soon as a project opens — it does not wait forpreviewReady. It only needs the project directory, and the Serverpod build it used to wait for takes minutes on a cold project, which left Settings unable to say which providers held a key. Starting early does not make the agent usable early:canSendstill requirespreviewReadyandAgentPanestill shows its "Serverpod is starting" gate, so the main interface is unchanged. The trade is that the agent now outlives a server stop, which also makes a restart cheaper.- API keys via Settings → Providers tab → OpenCode
auth.json/PUT /auth/{id}. - Permissions:
permission.asked→ reply withPOST /permission/{id}/reply{"reply": "once"|"always"|"reject"}. Any other value is a 400 and the agent stays blocked forever, so never swallow the error — keep the card up. - Questions:
question.asked→POST /question/{id}/replywith{"answers": [[label, …], …]}(one list per question) orPOST /question/{id}/reject. Both events carrytool.{messageID,callID}; the card is attached next to that tool part and the raw row is hidden. - Ground truth for events/endpoints is the running server:
curl <base>/doc.
Serverpod 4.1 split serverpod start in two: a detached runner
(serverpod runner serve) that is the development stack — generator,
watcher, compiler, server, database, Flutter apps — and clients that attach
to its socket. The CLI's own TUI is one client; App Studio is another. There is
no PTY and no output to scrape any more: stages, log entries, operations and the
Flutter app's URL arrive as structured events, and hot reload, hot restart and
migrations are commands on the same connection.
lib/runner/runner_protocol.dartdecodes the protocol (pure Dart, Widgetbook feeds it),runner_client.dartis the wire,runner_discovery.dartfinds a project's runner,runner_log.dartmirrors its history for the log pane,runner_ownership.dartrecords which runner is ours.ServerpodProcessControllerowns the policy;RunnerActionsViewis what the toolbar and the log pane see.- Ground truth is the CLI source —
serverpod_cli-4.1.0-beta.1/lib/src/runner/in the pub cache (runner_event.dart,runner_snapshot.dart,runner_socket_server.dart,runner_discovery.dart) — and a live runner: connect to the socket, send{"jsonrpc":"2.0","id":1,"method":"snapshot","params":{}}and read the events that follow.
- Newline-delimited JSON-RPC 2.0 on
<serverDir>/.dart_tool/serverpod/tui.sock, throughpackage:json_rpc_2— the package the runner serves it with. Every call passes an object forparams, even{}: the runner's handlers read named params andjson_rpc_2rejects an absent one there. snapshotfirst, theneventnotifications. The snapshot replaces the mirrored history (a reconnect may reach a different runner); every event appends. The runner subscribes a client the moment it computes the snapshot, so an event can overtake the reply on the wire —RunnerClient.attachholds events until the snapshot is applied, as the CLI's own client does.- The first
snapshota runner ever answers arms its Flutter auto-launch. A runner brought up byserverpod runner startwith nobody attached has not launched its apps yet; App Studio attaching is what starts them. A probe that only connects (liveness) arms nothing. - Raw pod output (
serverLine) is not shown, on purpose. The log is the structured one — the pod's own entries arrive there too. AflutterLogentry is only used when flaggedappendedToLines(a VM-service event such asFlutter.Errorthat never printed); otherwise it duplicatesflutterLines. - Stages:
starting→running, ordegraded(the project failed to build; the watcher recovers it on the next good save, orretryStart), thenstoppingwith anexitCode.runner.jsonmirrors the stage and survives the runner, so only a socket connection proves one is alive. - Operation
completedAttimes carry no zone while log times are UTC —DateTime.parsereads both correctly; do not "fix" one to match the other. protocolVersionin the manifest is 1. A runner publishing another one is refused (IncompatibleRunner), as the CLI refuses it.
- The runner is keyed by the server package, not the project root.
findServerDirectorylooks in the opened folder and two levels down, the way the CLI'sServerDirectoryFinderdoes, and refuses a folder with two servers.serverpod runner start --directoryis given exactly that path, so both ends agree on whichrunner.jsonto watch. - A Unix socket address is at most 104 bytes on macOS, and a project a few
folders deep blows straight through it.
runnerSocketPaththen uses the runner's registry link,~/.serverpod/runners/<projectId>/tui.sock(SERVERPOD_RUNNER_REGISTRY_DIRoverrides it) — the same choice the CLI's clients make. The probe project under the scratchpad needed this; it is not an edge case. - A runner that is
stoppingstill holds the package's lock;_settlewaits it out (15 s) rather than spawning one that would die on the lock.
- Opening a project attaches to a runner if one is already up — a terminal
serverpod start, an AI agent, or an earlier App Studio. Only when none is answering does App Studio runpub getand thenserverpod runner start --directory <serverDir>(launchRunnerProcess). serverpod runner start, not the hiddenrunner serve. It is the CLI's public way up: it handles the lock, a runner another client is starting at the same moment, and option mismatches. It returns only once the whole stack is up — minutes on a cold project — but the runner answers within seconds of being spawned, so App Studio attaches as soon as it does and lets the CLI finish on its own. Its output goes to the log until then. Its exit only means failure if it comes before an attach.- App Studio stops the runners it started and only detaches from the rest.
Closing a project, switching, quitting and Sparkle's relaunch all go through
stop(); for a runner App Studio found, that is a detach and a log line saying so. A runner may be the user's terminal session or their agent's. - Ownership is written to disk (
appstudio-runner.jsonin the temp dir: server dir + pid). The runner is detached by the CLI, so nothing takes it down with us;main()→cleanupOrphanedDevSession→reclaimOwnedRunnerstops one a crashed or hot-restarted App Studio left behind — over its socket, so its Postgres, Docker services and Flutter apps go down with it. A record only matches a live runner whose manifest names the same pid. It is consumed either way, so a stale one cannot become a signal for whatever inherits the pid. - No pid file, no group kill, no port reclaim for the runner. It is not our
child, and the preview has no fixed port to reclaim.
DevProcessKindis down to the agent. pub getstill runs before a start, and only then — never under a runner that is already up. The old step that deleted.dart_tool/serverpodto force a recompile is gone: that directory now holds the live runner's manifest, lock and socket.- The runner gets
TERM=dumb(and noCOLORTERM/COLORFGBG): nothing renders escape codes now. ANSI is stripped on decode as well, for what slips through. - A dropped connection is not a stopped runner.
_onDisconnectedkeeps the state while it re-resolves: a live runner is reattached (fresh snapshot); a manifest with exit code 0 means someone stopped it (idle, with a log line); no exit code or a non-zero one is a failure. - Only phase-level changes notify the controller's listeners — stage, preview
URL, app launching/running, registration code. Every listener of the process
rebuilds the shell, and a busy server logs many times a second; the log pane
listens to
RunnerLogfor the rest. A test pins this.
- No fixed port.
flutter run -d web-serverpicks a free one and the runner publishes it influtterAppState.url(and the snapshot'sflutterAppUrls). A progress update for a running app carries no URL — keep the last, as the CLI does.PreviewPaneloadsprocess.previewUrland recreates the webview if the app comes back on a different port. - The preview app is the one with a URL, else the one on
device: web-server, else the first. If the runner did not launch it (noauto_launch), App Studio asks once per attach — the runner ignores a launch for an app already starting. - "Live" is still decided in the page. With
-d web-serverthe app'smain()only runs once a page has connected, so the URL arriving means "load it", andmarkPreviewLive()(from_waitForFlutterMain) means "it is running". After the page connects the runner completes itsConnecting to <app> VM serviceoperation and sends no further app state — do not wait for one. - New projects get
device: web-serveron their app entry and nothing else (patchFlutterAppForPreview). Without it the runner's default device isweb-server-launch-browser, which also opens the system browser. Aweb-portalready there is left alone; App Studio no longer needs one. A project created elsewhere with nodevicestill works — the app also opens in a browser. - The CLI range in
assets/toolchain_manifest.jsonis>=4.1.0-beta.1 <4.2.0. 4.0 has no runner to attach to. The installer prefers a stable release in range and otherwise takes the newest prerelease, so this resolves to the beta today and to 4.1.0 once it ships.
lib/mcp/ (the serverpod mcp-server bridge and McpController) and the
kServerpodMcpEnabled flag were removed: everything it existed for — a hot
restart and "is a runner attached" — is a direct call on the runner socket now.
The runner still serves mcp.sock for the user's own AI agents; App Studio no
longer touches it. dart_mcp, xterm and kyroon_pty left pubspec.yaml with
it.
Hot Restart (⚡ zap, ⌘R) and Full Restart (refreshCcwDot, ⇧⌘R),
fast and slow. Both are ToolbarActionButton — an outline icon button with a
spinner in the icon's place while busy — so they cannot drift in size or
spinner size. Both are also in the Server menu.
- There is no Hot Reload button, on purpose. The runner watches the project
and hot-reloads on every save; a button would only repeat what has already
happened.
RunnerClient.hotReloadstill exists on the wire; nothing in the UI sends it. - Hot Restart is the runner's
hotRestart: recompile, reboot the server process, hot-restart every Flutter app. The RPC answers only once all of that has happened (measured ~1 s on the probe project), then the preview is hard-reloaded viarequestPreviewReload(). The runner triggers a browser refresh of its own, but through DWDS, which leaves the cachedmain.dart.jsin place. Only on success: reloading after a failed restart would re-serve the old app and make it look like the restart worked. - It reads
RunnerActionsView, is live only while a runner is attached with its serverrunning, and only one runner action runs at a time. A failure tints the icon destructive and puts the runner's message in the tooltip. - Icon-only, so the tooltip is load-bearing and carries the name as well as
the reason it is unavailable.
labelis still passed: it becomessemanticLabel, which is otherwise the icon glyph and nothing. - Full Restart replaced separate Start and Stop icons. They were two controls
for one question, and the pair invited the state that actually breaks things:
stopped on purpose, with a preview pane and an agent still waiting for a server
that is never coming back.
ServerpodProcessController.restart()is stop → start, and the new runner is App Studio's — even if the old one was not, since the user asked for the restart. - On a project that failed to build, Full Restart is
retryStart: the runner is still up and rebuilding is the part that needs doing. - It stays enabled with the server stopped, where it is a plain start. That is
the recovery path after a crash, and it is the only control that can bring the
server back — disabling it there would strand the user.
test/process/full_restart_button_test.dartpins this. isRestartingspans the stop and the start. Without it the UI sees a moment ofProcessState.idlebetween them and flips the button to "start the server", which reads as the restart having failed.isTransitioningis what the button actually reads.- The buttons talk to
FullRestartView/RunnerActionsView, not the controller, for the same reasonToolsViewexists: the state rules are worth testing without a runner behind them.
Between View and Window (MacAppMenu._installServerMenu): Hot Restart ⌘R,
Full Restart ⇧⌘R, then Create and Apply Migrations ⌘M and Create
Repair Migration. Everything that acts on the running server, in one place;
the log pane carries no actions.
- Its own listener on the process (
_syncServerMenu), not_sync, which rebuilds Open Recent and is too much to redo on every server state change. Each item is enabled exactly when its toolbar counterpart is, and the handler re-checks, since a shortcut can land before the menu catches up. - ⌘M was Window → Minimize.
MainMenu.xibno longer gives Minimize a key equivalent: two items on one shortcut leave AppKit to pick whichever menu it searches first. Minimize is still in the Window menu and the title bar. - Create and Apply is one command because "migrate" is one intent. It
creates a migration from the models, then applies whatever is pending — even
when nothing new was created, since an earlier migration may still be
waiting. A create refused for warnings (one that drops data) raises
ForceMigrationDialogquoting the runner's warning; cancelling applies nothing. The runner's apply also takes pending repair migrations, which is why Create Repair Migration only creates. - The commands raise a dialog and toasts, so they live in
ui/migration_commands.dartas functions of aBuildContext, andmain.dartruns them with the navigator's — the menu bar sits aboveShadApp.
- The log panel's minimum is half what it was: 29 columns of message
(
_kMinLogColumns) plus the level and time columns, ~355 px instead of the ~710 an 80-column line took. Longer lines wrap. - The window's minimum width fell by the same amount, 1024 → 670
(
MainFlutterWindow.swift;kMinAppWindowSizeinwidgetbook/host.dartmust match it — the catalog test builds the window there). - The toolbar folds only when it has to — measured, not at a breakpoint.
The editor buttons and Publish to Cloud sit in a
FirstThatFits(widgets/first_that_fits.dart): their full form is laid out at its natural width and used if it fits in what is left after the status's minimum (StudioToolbar.minStatusWidth, 180) and the two restart buttons; otherwise the compact form — one "Open in…" menu and a Publish icon. A fixed 960 px breakpoint folded them in windows with plenty of room, since the right width depends on which editors the project has. FirstThatFitspaints, hit-tests and exposes to semantics only the chosen child, and its element reports only that one as on stage, sofindand the inspector see one toolbar, not both versions.- The compact window's minimum width is 320 (
kCompactMinWindowWidth), set by the ~222 px control bar under the frame: at 240 the bar overflowed and the portrait frame was a 160 px sliver, and 300 still overflowed while resizing in the app.test/preview/compact_control_bar_test.dartmeasures the bar against it, but the test passes at 300 — the app needs the extra. - The minimum-size catalog test builds the window as a project opens — log
hidden (
StudioWindowMock(showLog: false)). With the log shown, the test font (every glyph a full em) doubles the measured log minimum and would report overflows no real window has.
The toolbar starts with ServerStatusIndicator: a green dot (running), a red
rounded square (stopped, failed or failed to build — the reason in the
tooltip), or a spinner beside the latest thing the runner is doing. The panel
toggle that used to lead the bar is Logs → Show/Hide Logs.
-
It is the only place an operation in flight is shown. The log's pinned footer of running operations was dropped once the status named them: the log records what finished, the status says what is happening.
-
The compact window shows the mark alone (
showMessage: false) at the left ofCompactControlBar, the bar under the device — the toolbar is gone in that mode, and there is no room for the line. The message moves to the tooltip. Desktop-minimized keeps the preview header, not this bar, and shows no status. -
ServerpodProcessController.serverStatus()computes it;statuswraps it as aServerStatusViewlistening to the controller and itsRunnerLog, because the operations it names arrive on the log, which the controller deliberately does not notify for. -
Latest wins. Of the runner's operations in flight, the most recently started names the status; with none, the phase does ("Fetching packages", "Starting the server", the app's
launchStage). -
An operation must run 400 ms (
kStatusOperationDelay) before it shows. The pod reports every non-internal log scope as an operation — an endpoint call included — and those last milliseconds; shown at once, a busy server would flicker a spinner on every request. A younger one is waited out:ServerStatus.settlesIntells the indicator when to look again. -
Runner labels lose the trailing dots the CLI writes for a terminal.
- A project opens with the log hidden (
LogViewController.projectOpened, frommain.dart's session listener, on every open and switch). The preview is what a user opened the project for; the log is Logs → Show Logs away.
Right of Server: Show/Hide Logs (⌘L), one checked item per log (Server, then each Flutter app by name), and a Log Level submenu (Debug, Info, Warning, Error).
- The panel's visibility, selected tab and level live in
LogViewController(created inmain.dart), not in widget state: the menu bar sits aboveShadAppand can only reach state outside aState. The panel, the level select and the menu all read and write the one controller; the select is keyed on the level becauseShadSelect.initialValueis read once. - Picking a log from the menu opens the panel too.
- The app items are rebuilt only when the runner's app list changes, judged by a signature of ids and names: the log notifies on every line. Rebuilds are serialised through one future chain, since two overlapping ones would remove and add the same items twice.
test/platform/macos_app_menu_test.dartdrives the realMacAppMenuthrough a recordedmac_menu_barchannel. Tests run as Android, where the plugin picks its no-op implementation, so it setsMacMenuBarPlatform.instancefirst.
ui/server_log/server_log_view.dart lays a record out the way the
serverpod start TUI does (serverpod_tui's LogMessageWidget): level, then
HH:mm:ss.SSS, then the message, in character columns of the mono font, so
wrapped lines stay under the message. Same labels (debug, info, warn,
error, fatal) and the same palette — grey, blue, yellow, red, green — taken
from the app's tokens (mutedForeground, primary, warning, destructive,
success) so it follows the theme.
- An error prints under its message in the error colour; a stack trace sits
behind
▸ N-line stack trace, as in the TUI, outside the selection. - A finished operation is
✓ time label (duration)and a rule to the edge — the TUI's section marker. A failed one is red with✗. The label is measured first and the rule takes the rest: aFlexiblelabel beside anExpandedrule splits the leftover space between them, and the rule stopped halfway. - The level filter is a minimum, named as the levels are: Debug, Info, Warning, Error. A failed operation counts as an error, a successful one as info.
- App Studio's own records (pub get, starting the runner) are dimmed, so the server's own lines stand out.
One ShadButton.ghost per external editor, reading “Open in Cursor” with a
trailing LucideIcons.arrowUpRight, between the toolbar's project path and the
restart buttons. ui/open_in_editor_buttons.dart draws them,
session/external_editors.dart decides which exist, and the widget imports only
ExternalEditorsView — the same narrow-interface split as Hot/Full Restart.
- A button appears only when all three are true: the project was configured
for that editor, the editor's
.appis on this Mac, and we know a way to hand that app a folder. The third is a hardcoded map (_editorApps), not a probe. opencannot tell you whether it worked, which is why that map is conservative. It exits 0 for an app that launched and ignored the path exactly as it does for one that opened it; a non-zero exit only means the bundle could not be launched. A button that silently does nothing is worse than an absent one.- Most of it goes through
open, which hands the request to LaunchServices and so routes it to the copy that is already running. Three forms:- The folder as a document —
open -a <bundle> <path>, for VS Code, which declarespublic.folderinCFBundleDocumentTypes. - A deep link —
open <scheme>://…, for Claude (claude://code/new?folder=), Codex (codex://threads/new?path=) and OpenCode (opencode://open-project?directory=). None of the three takes a folder as a document; all need the path percent-encoded. The installed check matters most here:open <scheme>://…exits 0 even when nothing claims the scheme. - The editor's own CLI, for Cursor alone — see the next section.
- The folder as a document —
- Claude's host is
code, notcowork. Both are real routes on/newand both takefolder, butcoworklands in Cowork with the directory merely selected whilecodeopens a Claude Code session in it — confirmed byLocalSessions.checkGhAvailable: cwd=<project>in~/Library/Logs/Claude. Theopen-filehandler the bundle also registers goes to Cowork too, which is why the deep link is preferred over handing it the folder as a document. Noq/promptis sent: that would prefill and start a task, and the button only promises to open the project. - Antigravity is the one
IdeIntegrationwith no button./Applications/Antigravity.appis not the IDE — it is a thin Electron shell around a Go language server, with noopen-filehandler and noCFBundleDocumentTypes, so a folder handed to it is dropped. The editor is a separate bundle,Antigravity IDE.app, and no route into it has been confirmed. It is out only because no build of it has been seen to open the folder it was given. - Order is preference order, and
ProjectStartedDialogfeatures the first. OpenCode is last deliberately: it only appears when the box was ticked, but App Studio already runs it in the Agent tab, so an editor it does not have is the more useful next step to feature. - Launch is by bundle path, not by name.
open -a "Visual Studio Code"asks LaunchServices to resolve the name, which can land on a different copy than the one_findAppchecked for./Applicationsis searched before~/Applications. The CLI route joins onto that same found path, so it cannot drift from it either. - A failed launch posts a notification with
force: true— the user just clicked a button in this window, so App Studio is frontmost andStudioNotificationswould otherwise suppress it. - The buttons are disabled, not hidden, until
PreviewPhase.ready(ProjectReadyView.projectIsReady). An editor opened mid-compile attaches to a server that cannot answer it, and a control that materialises minutes later is one the user never learns is there.ShadTooltipneeds theShadGestureDetectorwrapper to open over the disabled button. - The toolchain badge no longer shows a version. It used to sit in this spot
permanently reading
managed 3.35.0— a number the user cannot act on._ToolchainWarningBadgenow draws nothing unlessmissingRequiredis non-empty. That half has to stay: a required tool we cannot supply never reaches the first-run setup screen, so the badge is the only place it surfaces.
open -a Cursor <folder> always opens a classic editor window on the folder
and leaves the Agents Window sitting on whatever project it already had. For
a user who works in that window, “Open in Cursor” read as pointing at the wrong
directory. The fix is to spawn what Cursor's own bin/cursor script spawns:
/usr/bin/env ELECTRON_RUN_AS_NODE=1 \
<bundle>/Contents/MacOS/Cursor <bundle>/Contents/Resources/app/out/cli.js <path>- Cursor routes on how the request arrived, and only the CLI reaches that
window. In
out/main.js,resolveGlassCliFolderTargetsends a folder to the Agents Window (internally glass) asvscode:createNewComposer, gated onopenContext === 0— which comes fromVSCODE_CLI=1, and onlycli.jssets it.open -aarrives as a dock open, and the dock branch (getExternalWorkspaceOpenSource→dockWorkspace) requires!!urisToOpen[0].workspaceUri, i.e. a.code-workspacefile. A plain folder can never reach the Agents Window from there. - Through the CLI, Cursor adapts on its own and we encode no policy: Agents Window last active gets a new agent scoped to the folder, a classic window last active gets a classic window, and a project already open is focused rather than duplicated. All three measured against Cursor 3.15.6.
- This is not the
cursorshim onPATH. Both files ship inside every bundle, so nothing has to be installed; the shim at/usr/local/bin/cursorthat Cursor's own “install the command” action creates is never consulted._resolveClichecks both paths exist and falls back toopen -aif they do not, so a future layout change downgrades the button instead of breaking it. - Addressed inside the bundle rather than through
Contents/Resources/app/bin/cursor. That script is#!/usr/bin/env bash, so it needs aPATHwe do not control, and it carries a branch that willcurl … | bashan agent binary onto the machine when argument one isagent. - An older note here said a bundled CLI starts a second copy of the editor.
That does not reproduce. Spawned with launchd's bare
PATH— what a Finder launch inherits — the running instance was reused every time, the helper-process count never moved, and the call returned in ~1.2 s. - A deep link cannot do this, and it is the obvious thing to try.
cursor://file/<path>does open the folder, but it arrives as a protocol open, which is excluded from the Agents Window routing exactly like the dock is — so it still yields a classic window, fixing nothing. It also first raises Cursor's own “An external application wants to open … it may represent an attempted attack on your system” confirmation (security.promptForLocalFileProtocolHandling). - The
cursor://scheme's other routes are agent-shaped and take no folder:anysphere.cursor-deeplink/glassfocuses the Agents Window but cannot target a project, and/commandwrites a slash-command file rather than running one.
App Studio has three long-lived things to take down: the Serverpod runner it
started, opencode serve, and CEF's helpers. The runner is not a child at all —
the CLI detaches it — so see The Serverpod runner for how it is stopped and
reclaimed; the rest of this section is about real children. Getting them down is
harder than it looks, and the failure is silent — an orphan is reparented to
launchd, where nothing will ever clean it up. This was found with 17 orphaned
opencode serve processes on one machine, accumulated over days, one per
session.
Three mechanisms, and all three are needed:
AppLifecycleListener.onExitRequested(main.dart) is the only place a quit can be awaited.dispose()is synchronous, and on ⌘Q the isolate is killed at the OS's convenience — measured:opencode serveis an ordinaryProcess.startchild and survived every quit, and the runner is detached by design, so nothing at all takes it down with us.AppDelegatemust keep not overridingapplicationShouldTerminate, or this hook stops firing. It always answersAppExitResponse.exit: refusing would make the app unquittable, which is far worse than a stray process.- An awaitable
stop()on every controller that owns a child.AgentController.stop()andServerpodProcessController.stop()exist for this and are whatteardownSessioncalls. It used to callagent.dispose(), which could only fire the teardown and return — the stop was never awaited by anyone. - A pid file per child, reclaimed on next launch (
dev_session_cleanup.dart,DevProcessKind). The backstop for a crash, akill -9, and a Flutter hot restart — which re-runsmain()withoutdispose(), so the previous isolate's children are already orphaned by the time the new one starts. The runner's equivalent is its ownership record, stopped over its socket.
Process.killPid(-pid, …) signals the process group led by pid — and reports
failure by returning false, not by throwing. The old reclaim wrapped it in
a try/catch with a single-pid fallback in the catch, so the fallback never
ran. That mattered because only a child that called setsid leads its own group:
a PTY child does, so -pid reaches its whole tree, but a plain Process.start child
inherits our group and leads nothing, so -pid matched nothing, silently.
That is how orphaned agents survived every launch even with a reclaim in place.
_signal now branches on the return value. test/process/dev_session_cleanup_test.dart
pins it against a real child.
_process is assigned two awaits after start() begins (_freePort, then
Process.start). A second start arriving in that window found _process == null,
killed nothing, and then overwrote the handle when the first one finally
published — stranding a live serve that nothing could reach again, which is
exactly the "one orphan per port" signature. Every start now takes a generation
and kills its own process if it finds itself superseded; stop() bumps the
generation too, so it cancels a start in flight. Concurrent starts are reachable
from a provider save/remove landing on top of a project start, and from process
notifying on every poll tick.
- Claim state before awaiting a teardown, not after.
_startForProjectand_restartServePreservingChatset_boundProjectPath/_connectionfirst, then tear down withkeepClaim: true. Setting them afterwards left a window where_onSessionOrProcessChanged's idempotency guard saw an idle, unclaimed controller and started a second serve. startForSettingsclaims_startedForSettingsbefore its first await and re-checks it after. Set after, a Settings dialog closed mid-start found the flag false, declined to stop, and left a scratch serve nobody owned; and without the re-check the start proceeded even after being disowned.
dart pub get(serverpod_process.dart) andserverpod create(project_creator.dart) keep no handle and are never killed. Quitting during a cold first run leaves apub getto finish on its own. They exit by themselves; a cancellation path is real work.- Neither
OpenCodeServe.stop()norServerpodMcpBridge.stop()group-kills, so a grandchild (an LSP server opencode spawned) could outlive its parent. Not observed — every orphan found was the direct child — and a group kill on a non-leader is a no-op anyway, so this needs the child in its own session first. tool_probe.darttimes out its version probes withFuture.timeout, which does not kill the process. A wedged probe binary is orphaned.
- Flutter 3.44+ / Dart 3.12+ (host SDK for building App Studio itself)
- CEF / webview_cef:
brew install cmake ninja; thencd macos && pod install
(CEF binaries download intopackages/webview_cef/macos/third/cef/— gitignored)
make deps # pub get + CEF/pods (once, or when CEF is missing)
make start # flutter run -d macos
make clean # drop build/ + dist/ (keeps CEF, Pods)
make clean_all # also drop CEF, Pods and any old toolchain/ trees
make build_debug # unsigned zip
make build # Developer ID + notarized zipstart / build / build_debug only need CEF; the app installs its own tools.
Stale-embed trap: Xcode never deletes unknown files under Contents/Resources, so a build tree from before the toolchain moved out keeps a ~1.8 GB embed alive — it gets re-signed on every release and shipped in the archive (2.1 GB app instead of 350 MB). distribute_macos.sh deletes it defensively; make clean is the honest fix.
App Studio installs nothing privately. Each tool is installed the way its own instructions say, into the place they name, so the user's terminal, their editor and any AI agent see exactly the tools App Studio runs:
| Tool | Installed with | Lands in | PATH line |
|---|---|---|---|
| Apple's command line tools | xcode-select --install (Apple's own dialog) |
/Library/Developer/CommandLineTools |
— |
| Flutter SDK | official archive from releases_macos.json, unzip |
~/develop/flutter |
export PATH="$HOME/develop/flutter/bin:$PATH" |
| Serverpod CLI | dart install serverpod_cli@<version> |
~/Library/Application Support/Dart/install/bin |
export PATH="$PATH":"$HOME/Library/Application Support/Dart/install/bin" |
- The order is load-bearing. Flutter runs on the CLT's
git, anddart installrunsinstall_name_toolthrough the/usr/binshims, which the CLT make real. The Serverpod CLI is installed with the just-resolved Flutter'sdart.ToolchainInventory.installableis always inToolIdorder, and the controller refreshes between steps so each step sees the one before it. The first failure ends the run — nothing after it could work. - Versions come from
assets/toolchain_manifest.json(schema 2) — ranges, nothing else. A fresh install takes the newest release inside the range: the newest stable Flutter for this arch in Flutter's own feed, and the newest non-retracted Serverpod CLI on pub.dev, preferring stable (pub's own rule). Flutter's range deliberately has no upper bound: installs take the latest stable andflutter upgradegoes to the latest stable, so a ceiling would put the two at odds the day a new minor ships. There is no published manifest and no user-raised minimum any more. ToolsView.preflight()looks the versions up (install_plan.dart) — the plan dialog shows them, and a HEAD on the Flutter archive gives its size. It is the only network step before an install, and its failure is what the setup error dialog reports.- The Flutter archive is verified against the feed's sha256, unzipped into
~/develop/.flutter-install-<stamp>and renamed into place, so a crash leaves a staging directory (collectGarbageclears it) rather than half an SDK.unzipis real in/usr/bin— not one of the CLT shims — and is what Flutter's own instructions use. ~/develop/flutteris never overwritten unless App Studio put it there. An empty directory is removed; anything else stops the install and says where.flutter precache --webruns after, best effort — Flutter fetches the same components on the first web build.- Apple's installer reports nothing, so it is polled.
xcode-select --installreturns at once; the installer pollsxcode-select -pfor a realgitevery 3 s. It also watches for theInstall Command Line Developer Toolsprocess, so a window closed early reads as a failure after 15 s instead of an endless wait. Cancel stops the wait, not Apple's installer. - Apple's installer opens behind App Studio, where a user reading "follow
the installation steps in the pop-up window" cannot find it. Once its
process appears it is brought forward with
open -b com.apple.dt.CommandLineTools.installondemand— twice, a poll apart, since the window can follow the process.openon a running app activates it through LaunchServices and needs no automation permission, which an AppleScriptactivatewould. - A copy App Studio did not install is never changed, with one exception.
- An old Flutter of the user's gets
ToolAction.manual: setup blocks onManualUpdateDialog, which gives the exact command (the SDK's full path, pluschannel stablewhen it is on another channel) and a Check again button that re-probes without a restart. Installing a second SDK beside it would leave the old one first on theirPATH. - An old copy App Studio installed is
ToolAction.update— it runsflutter upgradeitself. A broken one of ours is replaced. - The Serverpod CLI is the exception:
dart installreplaces it in place and is cheap, so an old one is reinstalled whoever put it there. A copy the user had put in thedart installdirectory stays theirs in the record, so uninstall never offers to remove it.
- An old Flutter of the user's gets
InstallRecord (install_record.dart) lists every tool App Studio installed,
with its path, version and every PATH line it wrote, as {file, line}.
- It is what makes a tool App Studio's.
ToolSource.appStudiovsToolSource.host(the Tools tab's "App Studio" / "Native" badges), whether an old Flutter isupdateormanual, and what uninstall prefills all read it. - A
PATHline is recorded only if App Studio wrote it.addToShellPathwrites nothing when the directory is already on the user's login-shellPATHor the exact line is already in the file — a line that was already there is not ours to remove later. - The profile is
~/.zprofilefor zsh (what Flutter's macOS instructions say) and~/.bash_profilefor bash. Any other shell gets the lines shown in Settings → Tools to add by hand (manualPathLines). Both the Flutter line and Dart's go in the same file so they are found together. - Edits resolve symlinks first and write through a temp file and a rename. Dotfile managers link these files; renaming over the link would detach the user's config from their repo.
- Flutter is recorded before its first run, so an install interrupted during
flutter precacheis still one App Studio knows it owns and can replace. - A missing or corrupt record reads as "nothing installed by App Studio" — it only costs the uninstall prefill and the auto-update of an old Flutter.
Earlier builds kept every tool under
~/Library/Application Support/App Studio/toolchain, wrapper scripts in
…/App Studio/bin, and a fenced # >>> App Studio >>> block in .zshrc /
.bash_profile / .profile pointing at the wrappers.
ToolchainInventory.legacyInstallFoundis part ofneedsSetup, so a legacy user always lands on the setup screen — even when every tool is already on the Mac. Step one there isLegacyInstallNotice, which says what changed, but only when no Flutter is on the Mac (flutterFound). With one already there — usable or too old — App Studio just uses it, so there is nothing to announce; an old one gets the update prompt next, which says what it needs. Either wayremoveLegacyInstall()then runs: the three legacy directories (ToolchainPaths.legacyPaths, including the pre-renameAppStudiodirectory) and the fenced block in every rc file it could be in. Then setup carries on as a first run.- The old managed tree is never read. The probe does not look there, so an un-migrated install simply reads as missing tools — there is no state in which the app runs on the old copies.
- Uninstall runs the same cleanup, so a legacy block cannot survive it either.
ToolchainController(lib/toolchain/toolchain_controller.dart) owns the resolvedToolchain. Consumers readtoolchains.currentat call time — never capture it, or they strand on a snapshot taken before the user installed anything.- Every candidate is an ordinary install somewhere on the Mac; the record decides whether it is App Studio's. Flutter roots, in order:
FLUTTER_ROOT,flutteronPATH, the recorded path,~/develop/flutter,~/development/flutter,~/flutter,~/fvm/default. - Executable getters are nullable. Spawn sites check
toolchain.unavailableReason(id)first; a bare command name would only defer the failure toProcess.start. Toolchain.resolve()short-circuits at the first satisfied candidate (~350 ms).deep: trueenumerates every candidate and is only for the Tools tab — the full sweep is too slow to run beforerunApp.- The Command Line Tools are satisfied by
xcode-select -ppointing at a directory with a realusr/bin/git— the standalone tools or a full Xcode. Never execute/usr/bin/{git,otool,install_name_tool,clang,ar,ld}to find out: they are the CLT shims and pop a modal installer. Only App Studio's own install step opens that installer, on purpose. - Flutter is version-detected from
bin/cache/flutter.version.json, never by runningflutter --version— that can trigger a precache download on a cold SDK. The web-artifacts check is gone: it existed for the precached SDK App Studio used to ship. ToolProbetakes an injectableenvironmentforPATH,FLUTTER_ROOTandPUB_CACHE; tests that probe would otherwise find the Flutter on the machine running them.PATHis searched bywhichOnPath, in Dart — never by shelling out to/usr/bin/which. Every probe subprocess runs withincludeParentEnvironment: falseso a user's environment cannot sway version output, andwhichinherited that empty environment, so it had noPATHand returned null for everything.- A GUI launch only inherits launchd's
PATH, so the probe falls back to the user's login shell (login_shell_path.dart). Opened from Finder,PATHis/usr/bin:/bin:/usr/sbin:/sbinand nothing else, so a Flutter outside the conventional roots and adart installed Serverpod CLI both resolved to nothing — and the app offered to install tools the Mac already had. The installer uses the same capture to decide whether aPATHline is needed.- The shell runs
-l -i -c, and the-iis the load-bearing flag:.zshrcis the interactive rc and is where many users and installers put theirPATHlines, while a login-only shell reads.zprofileand misses them. Terminal.app is login+interactive, so this reproduces what the user sees when they typeflutterthemselves. - It is a fallback, not a replacement.
_whichtries the inheritedPATHfirst and only then spawns a shell (~80 ms). The result — including a failure — is memoised perToolProbe. - Discovery only. Candidates found this way are still executed by absolute path under the probe's own controlled environment.
- Do not wait for the shell's stdout to reach EOF. The shell's children inherit that pipe, so an rc that backgrounds anything holds it open long after the answer is written. The reader completes on the closing marker instead, which is also what makes the timeout fire.
${PATH}is braced for the same class of reason: unbraced, the trailing marker is read as part of the variable name and the answer comes back silently empty.
- The shell runs
- Serverpod's candidates check
~/Library/Application Support/Dart/install/binfirst, ahead of~/.pub-cache/bin. That is wheredart install serverpod_cliputs it; the pub-cache copy is the one that goes stale. PUB_CACHEis deliberately not set:package_config.jsonstores absolute paths, so a private cache would make every switch to an external editor re-resolve the project.- Version rules live in
tool_version.dart. A numeric-only suffix is a packaging revision, not a prerelease —2.53.0-3is newer than2.53.0, while4.0.0-beta.1is older than4.0.0.
gs://serverpod-appstudio-toolchain is reachable both directly and through a
Cloud CDN, and mixing the two up is the hazard. tool/lib/bucket_urls.sh
owns the distinction; nothing derives one URL from the other.
- Origin —
https://storage.googleapis.com/<bucket>/— is the bucket itself, and every publish-time read uses it.object_exists/published_sha, the re-download-and-re-hash verification, the appcast read-back, andpublished_max_build's feed read are all asking what is in the bucket, and a cached answer is not an answer to that question. - Public —
https://asdl.serverpod.dev/— is the same bucket behind the CDN, and every URL handed to a user uses it: the appcast enclosures, thedownloads/macos.jsonlinks, andSPARKLE_FEED_URL. - The build-number gate is the one that cannot be allowed to read stale. A
low answer from
published_max_buildapproves a build number already published, which blinds every installed copy to updates forever and cannot be undone.set_version.shandpublish_dist.shboth read the origin feed, andfetch_uncachedcachebusts on top of that. - The origin URL must keep working forever. Every copy of App Studio built before the CDN existed has it compiled in as its Sparkle feed. The CDN is a second route to one bucket, not a move; retiring the old URL strands those installs permanently.
- The
tools/objects andmanifest.jsonstay in the bucket. Nothing publishes them any more — App Studio installs its tools from their official sources now — but builds from before that change still download from them on first run, and they have to keep working until those builds update. --bucket=NAMEfalls back to the origin for the public URL too (public_base_url). The CDN fronts exactly one bucket, so stamping its host into a feed built from a different one yields downloads that 404 — or, worse, silently resolve to the production object sharing that path.- Only the mutable objects are invalidated —
appcast.xml(max-age=300) anddownloads/macos.json(max-age=60).invalidate_cdn_pathis best-effort and returns 0: the object is already in the bucket, the TTL it waits out is minutes, and everything that must not be stale reads the origin anyway — so failing a release that already succeeded would be the worse outcome. Invalidating an immutable object is meaningless and the quota is small, so nothing else gets one. - Negative caching is off on the backend bucket, deliberately. The cache
mode enables it by default and would hold a
404for 120 s; every publish path here probes for object absence, so a freshly published object answering 404 from the edge would resolve an archive to the wrong build number. - The CDN is a global external Application Load Balancer in the same project
(
serverpod-appstudio): backend bucketasdl-backend, url mapasdl-url-map, Google-managed certasdl-cert. Those names are inbucket_urls.shbecause the invalidation needs them.
Releasing is two deliberate halves, so a build can be tested on another Mac before any existing install is offered it:
make release V=1.2.3 # set the version + build + notarize + upload —
# downloadable by direct URL, NOT yet an auto-update
make promote # add it to the appcast: auto-update goes live
make set_version V=1.2.3 # the individual steps release is made of
make build # signed + notarized zip in dist/, nothing uploaded
make publish # upload the built zip (re-runnable: identical bytes
# are recognised and skipped)
make build_debug # unsigned, local only, never uploadedmake publish (tool/publish_dist.sh) uploads the zip and its .sha256
sidecar and moves the preview entry of downloads/macos.json; the appcast
is untouched, so existing installs see nothing. make promote
(tool/publish_appcast.sh) is the step that reaches users — it confirms
interactively, inserts the build into the feed, and catches the latest
entry up.
downloads/macos.json is the pointer the website's download page reads:
latest (the newest build in the appcast) and preview (the newest upload,
which may not be promoted yet), each a full URL plus version, build and sha256.
When nothing awaits promotion the two entries are identical. It is mutable,
served with max-age=60, and derived entirely from published state by
tool/publish_downloads.sh — both publish scripts re-run it, so it never
needs running by hand and is always safe to re-run.
Defaults, all overridable from the environment: bundle id
dev.serverpod.appstudio, team Serverpod AB (WK2NRY4GS4),
SIGN_IDENTITY="Developer ID Application: Serverpod AB (WK2NRY4GS4)",
NOTARY_PROFILE=appstudio, NOTARIZE=0 (the make targets set it to 1). The
zip lands in dist/macos/.
Notarizing needs a one-time credential, stored in the Keychain under the profile name the scripts look for — an App Store Connect API key, not an Apple ID password:
xcrun notarytool store-credentials "appstudio" \
--key ~/private_keys/AuthKey_XXXXXXXXXX.p8 \
--key-id "XXXXXXXXXX" \
--issuer "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"An unsigned build (SIGN_IDENTITY="" ./tool/distribute_macos.sh) reads as
corrupt once it has been copied to another Mac — Gatekeeper says “App Studio
is damaged and can't be opened”, which is quarantine rather than actual damage.
xattr -cr "/Applications/App Studio.app" clears it. Prefer shipping a notarized
build; this is for a build that never leaves the machine.
Artifact is a .zip, not a DMG. Built with ditto -c -k --norsrc --keepParent — plain zip mangles symlinks and the CEF framework is full of
them, and --norsrc is load-bearing: without it ditto archives the build
machine's xattrs as inline ._foo AppleDouble entries, which Archive Utility
extracts as literal files (it only folds the sequestered __MACOSX form back
into xattrs). Those land as unsealed contents in every framework root and
Gatekeeper rejects the app — "unsealed contents present in the root directory
of an embedded framework" — for anyone who unzips by double-clicking in
Finder, while Safari's auto-extract goes through ditto itself and hides it.
make_zip asserts the archive is clean. The app is
notarized, then stapled, then re-zipped: a zip cannot carry a ticket but the
app inside it can, and Sparkle installs that app directly. The bundle is
App Studio.app (with a space); download filenames stay AppStudio-… so URLs
are typeable.
+N in pubspec.yaml becomes CFBundleVersion, which is the only thing
Sparkle compares. It must increase on every release and must never reset
when the marketing version changes: 1.0.0+7 → 1.1.0+8, not 1.1.0+1. A
repeat or a rollback makes every installed copy see "you're up to date" forever,
and because published objects are immutable it cannot be fixed by re-uploading.
Never hand-edit the version. Use make set_version V=1.2.3 (or
V=patch|minor|major|build), which reads the live appcast and the
downloads/macos.json pointer — the latter is what knows about a build
uploaded but not yet promoted, which the appcast alone under-reports — and
picks a counter that clears everything, including releases made from another
checkout. publish_dist.sh re-checks the appcast gate before uploading. All
of it shares tool/lib/pubspec_version.sh;
test/tool/version_gate_test.dart covers it.
tool/publish_dist.sh derives the path from pubspec.yaml
(version: 1.0.0+1 → releases/macos/1.0.0/b1/AppStudio-1.0.0-arm64.zip)
and uploads with --if-generation-match=0, so objects are immutable — a
re-release means bumping the build number, never overwriting a URL someone
already has. A .sha256 sidecar goes up alongside it. A re-run for bytes
already published is recognised (via the sidecar) and skipped, so make publish is idempotent; the same path holding different bytes is a hard
error.
Four gates run before any upload, all verified to reject:
- the build number must beat every
sparkle:versionin the live appcast, - the archive is unpacked and its
CFBundleShortVersionStringandCFBundleVersionmust match pubspec (stops a stale archive being served under a new version number), - it must contain no
Contents/Resources/toolchain(stale embed), - it must have a stapled notarization ticket (
--allow-unnotarizedoverrides for internal builds, and says so loudly).
Reference sizes for a clean, notarized build: .app 350 MB (CEF is 302 MB of
it). If you see ~2.1 GB, the build tree is stale — run make clean.
Sparkle 2.9.5 via Swift Package Manager, pinned in Package.resolved. A small
Swift bridge (macos/Runner/SparkleUpdater.swift) exposes it to Dart over
dev.serverpod.appstudio/updates; lib/update/ is the Dart half.
Sparkle owns the update UI. A Flutter dialog cannot show install progress after our own process has terminated, which is exactly when it matters.
appcast/macos/appcast.xml MUTABLE — what installed copies poll (max-age=300)
downloads/macos.json MUTABLE — latest + preview links for the website (max-age=60)
releases/macos/<v>/b<n>/*.zip immutable
SUFeedURL/SUPublicEDKeycome frommacos/Runner/Configs/Sparkle.xcconfig, whichRelease.xcconfigincludes andDebug.xcconfigdoes not. A debug build therefore resolves both to empty, reportsconfigured: false, and greys the menu item out instead of trying to replace itself with a release build.- xcconfig treats
//as a comment anywhere on a line, including inside a value. Writing the feed URL literally silently truncates it tohttps:. That is why the file builds the slashes from$(SPARKLE_SLASH). Verify withPlistBuddy -c 'Print :SUFeedURL'on the builtInfo.plistafter any change. SUAllowsAutomaticUpdatesisfalse, which removes the silent-install option from Sparkle's UI entirely. An unattended relaunch would kill a running Serverpod server, the CEF preview and the agent with no warning.
UpdateController pushes busy state to Swift (setSessionBusy) whenever
the server or agent starts or stops. Push, not pull: Sparkle asks its delegate
synchronously on the main thread, so calling back into Dart and waiting there
would deadlock the thread that has to produce the answer.
Scheduled checks are vetoed while a session is live; a user-initiated check
never is. Before relaunching, Sparkle waits on prepareForRelaunch, which runs
teardownSession() — the same function dispose() uses, so the quit path and
the update path cannot drift. It is time-boxed (15 s in Dart, 20 s in Swift):
leaving a stray process is better than stranding the user mid-install.
Sparkle embeds nested bundles (Versions/*/Updater.app, Versions/*/Autoupdate)
and, by default, XPC services.
- The XPC services are stripped by
distribute_macos.sh, after the build and before signing (macos/Runner/Scripts/strip_sparkle_xpc.sh). They exist only for sandboxed hosts. Leaving them in is not just dead weight: the wrapper signing pass matches*.appand*.framework, and an.xpcmatches neither, so it would ship unsigned inside a framework we re-signed. The script also removes the framework-rootXPCServicessymlink — a dangling link there fails notarization with an opaque error. - A build phase is not enough, and this failed in practice. Xcode embeds an
SPM binary framework in an internal step that is not ordered after custom
build phases, so any run that re-copies
Sparkle.frameworkundoes a build-phase strip. It hides itself too: the next incremental build skips the copy and looks clean again. The build phase is still there for plainflutter buildruns, butdistribute_macos.shdoes it at the one point where nothing can copy over the result, and asserts afterwards. - Both wrapper
finds use! -type l.findmatches by name, and Sparkle's framework root is symlinks (Updater.app,Autoupdate,Versions/Current); without it they get signed through the link in depth-sort order. codesign_onecarves Sparkle out of the*.appentitlements arm. Its helpers get hardened runtime and nothing else — handingdisable-library-validationto the process that installs your updates is backwards.distribute_macos.shasserts after signing: no.xpcsurvives, and each helper reportsTeamIdentifier=WK2NRY4GS4, aTimestamp=, and no app entitlements.
generate_keys puts the ed25519 private key in the login Keychain;
generate_appcast reads it via SecItemCopyMatching, so it never touches a
command line or shell history. The public half is committed in Sparkle.xcconfig.
If that private key is lost, every already-installed copy has the old
SUPublicEDKey compiled in and becomes permanently un-updatable through
Sparkle. Recovery means a new key, a manually distributed build, and an
out-of-band notice to every user.
.sparkle-tools/<ver>/bin/generate_keys -x sparkle_private.pem # export
# store in GCP Secret Manager, then:
rm sparkle_private.pemStore it somewhere that does not also grant bucket write access: one
compromised credential should not be enough to ship a signed malicious update.
publish_appcast.sh refuses to run if a *.pem / sparkle_priv* / *ed25519*
file is sitting in the repo.
make release V=1.2.3 # set version -> build -> notarize -> upload
make promote # add the uploaded build to the feed (confirms first)tool/ensure_sparkle_tools.sh fetches generate_appcast / sign_update /
generate_keys, reading the version from Package.resolved rather than a
hardcoded constant — the tools and the embedded framework must be the same
release or the feed's signatures will not verify.
The appcast is updated incrementally. publish_appcast.sh downloads only
the one zip being promoted — from the bucket, never from dist/, verified
against its .sha256 sidecar, because the feed describes the published object
and that object is what gets signed — runs generate_appcast over just that
file, and inserts the resulting item into the live feed. Existing items pass
through byte-for-byte with the signatures they already have, and only the new
enclosure is probed for reachability: the old flow re-downloaded, re-signed
and re-probed every published release (N×350 MB) to say something about one
build. Promoting a build already in the feed is a no-op that still refreshes
downloads/macos.json, so a half-finished promote can just be re-run.
generate_appcast only sees a flat directory, so it builds the enclosure URL
as <prefix><filename>; the script rewrites it to the real
releases/macos/<v>/b<n>/ object and refuses an item that names any other
URL, a different build number, or no signature. Every release stays in the
feed forever — items are ~0.5 KB, and the monotonic build-number gate runs
against the feed itself before anything is inserted.
The signing key is checked against the shipped key before anything uploads.
generate_appcast signs with the Keychain private key; installed copies verify
with the SUPublicEDKey in their bundle. If those diverge, every update is
rejected by every install, silently, and no re-publish fixes it — the old
builds are already out there. When reading the key from the xcconfig, match to
end of line: base64 ends in =, so splitting on = yields an empty string.
Deltas are off (--maximum-deltas 0). generate_appcast enables them by
itself the moment two versions are present, and writes <sparkle:deltas>
enclosures for .delta files this script neither uploads nor knows how to
name — which put two dead URLs in the live feed before it was caught. Turning
them on is real work, not a flag: the files need an immutable path, and since
every run regenerates every delta, the upload cannot use
--if-generation-match=0 the way release zips do. A guard now rejects any
enclosure that is not a published object, so a surprise like this fails before
the feed goes live rather than after.
Reads of the appcast must bypass the cache. It is served with
max-age=300, so a plain GET returns the previous feed for five minutes
after a publish. That is not cosmetic: published_max_build reads this feed,
and a stale answer would let the build-number gate approve a number already
taken. Use fetch_uncached from tool/lib/pubspec_version.sh, and read the
origin URL — see One bucket, two URLs. Now that a CDN sits in front of the
bucket there are two caches to get past, and only the origin read has just one.
The app's own feed URL is the CDN's, and publish_appcast.sh invalidates that
path at the edge on every publish.
A zip auto-extracts to ~/Downloads, and an app launched from there is
translocated by Gatekeeper onto a read-only mount, where it physically cannot
replace itself. UpdateState.translocated detects this and canCheck goes
false, so the menu item greys out rather than offering an update that cannot
install. Sparkle would also catch it, but later and less clearly.
- Component library:
shad(ShadApp,ShadTheme,LucideIcons). No Material-heavy custom widgets unless necessary. Replacedshadcn_ui— sameShad*API, higher fidelity to upstream shadcn/ui. Two defaults differ:ShadTabs.expandTabsnow defaults tofalse(the Settings tabs passexpandTabs: true), andghost/outlinebuttons take their foreground fromforegroundrather thanprimary, so they are no longer brand-blue — that change was kept. shaddeliberately does not re-export the extensions ondouble/Duration/List/TextStylethatshadcn_uidid, norboxy/flutter_svg/universal_image. Depend on those directly if you ever need them.- Theme (
app_theme.dart): Slate base, Nova style, Medium radius (--radius10, so the component step is 8). Serverpod brand blue is the accent:ShadSlateColorScheme.…().applyAccent(kBrandBlue)derivesprimary,primaryForeground,ringandselectionfrom the one constant, so there is nothing to keep in sync by hand. - Fonts:
.AppleSystemUIFont(UI),Menlo(terminal viakMacMonoFont), Inter Black for the headline roles (kHeadlineFont). The text theme is otherwise stock —ShadTextTheme(family:)swaps only the family, so every weight, size and line height matches upstream shad. h1Large…h4are Inter Black (900) and nothing else is.assets/fonts/Inter-Black.ttfis bundled — 400 KB, one weight, OFL — rather than reached for through shad's ownpackages/shad/Inter, so the display face cannot change under a package bump. Headlines are also the one place the weight correction below does not apply: Inter is the typeface shad's metrics were drawn for, and the static cut carries nowghtaxis to correct on anyway.- A
ShadCardtitle:resolves toh3, so it is now a Black headline. That is right for a title and wrong for a label — the tools install banner passes an explicittitleStyle: textTheme.largefor exactly this reason. Check any new card title before letting it default. - Our font's weight axis moves about twice as fast as shad's, and the whole type scale is corrected for it. Measured over the same string at 14px: Inter (shad's default) widens ~1.65px per 100 weight units,
.AppleSystemUIFont~3.17px — so an uncorrectedw500lands where Inter'sw600would. Both axes resolve correctly and neither synthesises bold; it is a typeface difference.app_theme.dartrebuilds everyShadTextThemerole with awghtvariation at the corrected value, including thew400ones — a variation beats an inheritedfontWeight, so a component that copies a role and bumps the weight (ShadCheckboxdoes this to its label) lands on the corrected weight instead of a too-heavy one. - Never write
copyWith(fontWeight: …)on UI-font text — it is silently ignored. Every role already carries a variation and the variation wins, so a raw weight leaves the text at its role's weight. Use the.medium/.semiboldgetters from theUiTextWeightextension instead. Monospace is the exception:Menlois not variable, sofontWeightis the right lever there and the variation is a no-op. textTheme.smallis the label role, not body copy. It isw500atheight: 1.0(font-medium leading-none), so prose set in it reads bold and cramped the moment it wraps. Use it for single-line labels, list rows and badges. For anything that wraps usetextTheme.muted(w400,height: 1.43), and for a dialog's lead paragraph useShadDialog'sdescriptionslot, which resolves tostyle.bodyovertextTheme.muted. The agent transcript is the one place that keepssmallfor prose, and only because it sets an explicitheight: 1.4.- Custom widgets read the theme; they do not carry colors. Use
scheme.*for color,theme.radii.{sm,md,lg,full}for corners andtheme.style.*for geometry — a literalColor(0x…)orBorderRadius.circular(n)in a widget is the thing to fix, not to match. Failure states arescheme.destructive, never a private red. - Success and warning have no shadcn token, so they ride in the scheme's
custommap and are read through theAppColorSchemeextension asscheme.success/scheme.warning. One value per status, picked to be legible on that brightness's background, covers both the dot and its label. - Layout constants (
app_theme.dart):kPaneHeaderHeight(36),kDividerWidth(1),paneBorderSide(),kSplitHandleWidth(6). - Pane chrome: headers use
scheme.muted; content usesscheme.background. UsepaneBorderSide()for all 1px dividers. - Segmented controls:
IconSegmentedControl/TextSegmentedControlsharewidgets/segmented_control_theme.dart, which resolves color and geometry from the theme's tab tokens — the strip/segment treatmentShadTabsuses — so both follow the style preset like a built-in. Hover moves the foreground, not the fill, so no blend has to be invented.onMutedSurface: trueon muted pane headers swaps track and selection. - Header breathing room is set by
kPaneHeaderHeight, not by the controls. All three (icon group, text group, standalone button) come out 30px from the preset's tab tokens, and the bar is 40 so they clear it by 5 top and bottom. Tightening the strip inset instead buys that gap out of the space between the strip edge and the segment pills, which is what makes the labels look cramped — that was tried and reverted. Retune the bar. - Component themes are near-stock.
app_theme.dartkeeps two: the 700 ms tooltip wait, and acardThemethat drops the radius from nova'sxl(14) tolg(10) — the step buttons and select triggers use, so a card no longer looks rounder than the dropdown beside it — and halves the 16px padding, because our cards stack as list rows rather than standing alone. The tooltip delay is there because the default flashes tooltips while the pointer crosses a toolbar. Two overrides that used to live here are gone on purpose: the darkborder/inputpair (slate dark hadborder == mutedinshadcn_ui, butshadsets a translucent white and dividers show fine) and the soliddestructivebutton fill —destructiveis upstream's softbg-destructive/10tint everywhere now, including the Uninstall confirm. - Prefer a shad component over a hand-built one. Panels drawn as a
DecoratedBoxwith tintedscheme.muted/scheme.borderare the pattern to replace:ShadCardalready carries the right fill, hairline border, radius and padding. LikewiseShadSeparatorover aDivider, andShadRow/ShadColumn/ShadGap— whosespacingis in 4px theme steps, not pixels — overSizedBoxspacers. Both Settings panes are converted; there is no tintedDecoratedBoxleft in the dialog. The install banner is aShadCardonscheme.muted. - One table,
widgets/settings_table.dart, for the setup dialog, Settings → Tools and Settings → Providers. They ask the same question of different subjects — what is this, and where does it stand — and drawing it three times is what let the setup dialog offer a plain download where the Tools tab said "Update needed".versionBadgelives beside it so the1.15.13 → 1.18.4form is written once.SettingsTablerenders nothing when handed no rows —ShadTable.listasserts on an empty child list, and an empty inventory is reachable, sinceToolchainController.createfalls back to one when the probe throws rather than refusing to launch. - The Tools tab deliberately has no per-row buttons, install path or version-range line. Those were answering questions nobody asked; installing is the dialog footer's job, and removing everything is the App Studio menu's.
- Putting the tools on the user's
PATHis not a choice, and there is no checkbox for it. It is what lets an external editor or AI agent find the toolchain, so it is part of installing: the installer writes the official line for each tool it installs, unless the user's login shell already has that directory. The Tools tab says nothing about it in the ordinary case; for a shell App Studio cannot edit (fish, nu) it shows the lines to add by hand in aCommandBlock. - The Tools tab has no Update All. A copy App Studio installed that falls out of range becomes
ToolAction.updateand appears in the footer's Install button like anything else missing; a copy the user installed gets a banner with the command to run. - Settings → Providers lists only providers that hold a key, each with a green Connected badge and a trash button, and an Add Provider button that opens
add_provider_dialog.dart(provider dropdown, key field, Connect/Cancel, no close X, narrower than Settings). It used to render a card with a key field for all seventeen curated providers, which buried the two that were actually configured. The dropdown filters out anything already connected: the fix for a bad key is remove-then-add, not a second add. - The Add Provider dialog does the save itself and stays open on failure with the key still in the field, because losing a pasted key to a dialog that closed is the worst possible response to an error. Be careful what it claims:
saveApiKeywrites to the auth store and treats that as the source of truth — it swallows a failedPUT /auth/{id}and restarts serve — so a wrong-but-well-formed key does not fail here. What the pane's_connectcan report is an exception from the write, and the provider not appearing inconnectedProvidersafterwards, which is "that did not take effect" rather than "your key is wrong". Do not word it as validation. - Its width comes from
ShadDialog.constraints, not a fixed-width child: the dialog sizes itself to its widest content, so a narrower child just leaves the inputs floating short of the edge. - Every dialog's
childgoes throughDialogBody(widgets/dialog_body.dart) —const DialogBody()alone for a dialog that is only a title and a description. shad puts onegapbetween every part of a dialog, and nova sets it to 4, which leaves the buttons sitting on the text;DialogBodymakes the space above the buttonskDialogActionsSpacing(16, shadcn'sgap-4) with content or without. Do not pad the buttons or the end of the content by hand — that is what it replaced, one different amount per dialog.test/widgets/dialog_body_test.dartmeasures the spacing and fails on a file with more dialogs thanDialogBodys. ShadBadge.secondaryis invisible on amutedcard — slate definessecondaryandmutedas the same colour, in both brightnesses. Use.outlineon a muted surface, and check anysecondary-on-mutedpairing before assuming it reads.onPressed: nulldoes not grey out aShadButton— passenabled: falsetoo. The dimming is anOpacity(.5)driven byShadState.disabled, which shad sets from theenabledproperty alone; that defaults totrueno matter whatonPressedis. A null handler on its own gets you a button that is inert but looks live, which reads as a broken click rather than an unavailable action. Settings → Tools passes both. The toolbar's_IconActionderives it asenabled: onPressed != null, so Start and Stop dim like everything else.- A
ShadTooltipnever opens on a disabledShadButton— which is exactly when its text is worth reading.ShadTooltipowns noMouseRegion: it injectsonHoverChangeinto the theme's hover strategies and relies on the child to report hover, and a disabled button puts its content behind anAbsorbPointer, so nothing ever reports. Wrap the button in a bareShadGestureDetector— that sits outside the absorber and reports hover regardless.HotRestartButtondoes this, because "start the server first" is the whole point of the tooltip there. - Success and warning badges go through
appSuccessBadge/appWarningBadge(app_theme.dart), which wash the status colour at 10% (light) / 20% (dark) the way shad tintsdestructive. Warning, not destructive, is right for "not installed yet": nothing is broken and there is a button on the same screen that fixes it. - Split resize: panes meet with no gap. Draw a full-height 1px
ColoredBoxin aStackon the split line; overlay a transparent drag handle (kSplitHandleWidth). Do not use a separate wide column orDecoratedBoxborder behind opaque pane content — it won't show.
- Shell theme ≠ preview theme. Preview light/dark toggles a JS shim (
preview_theme.dart) that fakesprefers-color-schemeinside CEF. - View modes:
PreviewViewMode— portrait phone (default), landscape phone, desktop. Defined inpreview_device.dart. - Reload: there is no reload button, and no second path. Every reload goes through
previewReloadToken→PreviewPaneState.reload(), so it is always the hard one — a plain navigation re-serves the cachedmain.dart.js, so a rebuilt app looks unchanged. Two things bump the token:ServerpodProcessController.requestPreviewReload()and a successful Hot Restart (which goes through it). The runner's own hot reload on save never does — a hard reload would throw away the state it kept. The first load needs no reload: the runner publishes the URL before the app'smain()has run, and loading it is what connects DWDS. Re-apply the theme shim afterwards: it is injected at load start with the brightness captured when the webview was created. - A hard reload has to take three separate caches, and missing any one of them shows the previous build.
reload()runspreviewCachePurgeScript(preview/preview_cache_purge.dart) first, because a service worker and its Cache Storage entries are page state: they outlive the navigation entirely and then answer the new document's requests out of the build that registered them, which no amount of cache-bypassing on our side can reach. Flutter web in debug registers no service worker, but the previewed app is the user's and may. The purge is asynchronous andexecuteJavaScriptcannot await it, so_purgePageCachespollswindow.__appStudioCachesPurged— bounded at 1s, then reloads regardless, since not reloading is the worse failure.reloadIgnoreCache()then covers the other two: the fork'sWebviewHandler::reloadIgnoreCacheissues DevToolsNetwork.clearBrowserCachebeforeCefBrowser::ReloadIgnoreCache(), because the latter only bypasses the cache for the requests this navigation makes — the stored entries survive it, so the next plain navigation (DWDS triggers one of its own) serves the old bytes again. CEF exposes no cache-clear API; the DevTools protocol is the route, and commands on one session run in order. - Switching projects clears the profile. The CEF request context (HTTP
cache, localStorage, service workers, cookies) is global and in-memory — it
outlives the per-project webview. Each run is served on whatever free port
Flutter picked, so two projects usually get different origins, but ports are
reused and nothing promises it; project B's first load could be answered out
of project A's state.
_ensureWebViewtracks which project the profile last served (module-level inpreview_pane.dart— the pane's State unmounts when a project closes, the profile does not) and on a switch calls the fork'sWebViewController.clearBrowsingData— DevToolsStorage.clearDataForOriginwithstorageTypes: "all"plusNetwork.clearBrowserCache, the same pair Chrome's own "Clear site data" uses — then runs the fullreload(), since the initial navigation may already have been served from the old state. The tracker is only advanced after the clear succeeds, so a failed clear stays due on the retry. Same-project restarts are deliberately untouched: the clear keys on the project changing, not on the webview being recreated, so localStorage survives a Full Restart within one session. Keeping the profile in-memory (thecache_pathnote below) is unchanged — this is about state crossing projects inside one app run, not across launches. - The runner is not racing us. The runner's
hotRestartawaits the full compile, the server reboot, the Flutter apps' hot restart and its own browser refresh before it answers — so by the timeServerpodProcessControllerbumps the token, the web server is already serving the new output. If the preview still shows old code, it is a cache and not timing. - The header's reload button is gone on purpose. Hot Restart now force-reloads the preview itself, so the button's only remaining job was papering over a reload that had not happened — and the honest fix for that is the token, not a second control that does the same thing by hand.
- Backdrop: CEF composites with alpha, so an opaque
ColoredBox(previewBackdropColor) sits under the surface, following the previewed app's brightness, not the shell's. - CEF sizing:
PreviewSurfaceSync+burstSyncPreviewSurfacekeep the webview sized on panel/mode changes. Defer layout reads to post-frame callbacks; avoidMediaQueryininitState. - webview_cef: path dependency
packages/webview_cef(fork of 0.5.1). Includes CEF resize surface sync and skips embeddingHelper (Alerts). CEF itself is fetched bypod installintomacos/third/cef/(gitignored). root_cache_pathis set, and it has to be. Left unset, CEF falls back to~/Library/Application Support/CEF/User Data— one directory for every CEF app on the Mac that also left it unset, and the one holdingSingletonLock,SingletonCookieandSingletonSocket. That is the "unintended process singleton behavior" CEF warns about on every launch: another vendor's CEF app can hold the lock our browser needs.CefWrapper.mmresolves~/Library/Application Support/<CFBundleName>/CEF—App Studio/CEFhere, so the uninstaller's recursive sweep of the support directory takes it too — and passes it throughsetMacRootCachePathbeforestartCEF(). It is set outside the bundled-helper check that guards the other three paths, because it applies in single-process mode as well.cache_pathstays empty on purpose, so the HTTP cache remains in-memory only. Setting it would start persisting the previewed app'smain.dart.jsacross launches, which is the exact thingreloadIgnoreCache()exists to defeat.root_cache_pathalone does not turn that on.CefShutdown()is one-way, and the plugin outlives it — every way back into CEF is gated onwebview_cef::cefIsShutdown().teardownSessionquits CEF first (its helpers are our children), and only then does Flutter tear the widget tree down, soPreviewPaneState.dispose()'sclosecall always arrives after the shutdown. Ungated it reachedCefBrowser::GetHost()on a dead wrapper, which isshutdown_checker::AssertNotShutdown— aSIGTRAPthat took the app down on quit in a debug build, and silent undefined behaviour in release. The same window is what an update lands in:prepareForRelaunchquits CEF and Sparkle then installs for several seconds with the app still live, where a mouse move over the preview is enough to reach in.HandleMethodCallanswers without touching CEF once shut down; the platform entry points that bypass it (theNSEventkey monitor, the IME forwarders, the vblank tick) carry their own gate; andCefWrapper.stopDrivingCEFkills the pump timer and theCVDisplayLink, which otherwise call into a library that is no longer there ~60 times a second.quitcloses the browsers before it shuts CEF down. ACefRefPtr<CefBrowser>still held atCefShutdown()is a wrapper whose release lands in that same dead library. The handler force-closes, drops the renderers and pumps the loop a bounded number of times so the close can land — nothing else drives CEF here, since macOS runs it on an external message pump.browser_map_was not the only reference —current_focused_browser_is a file-scope one, and it crashed every ⌘Q. Nothing cleared it, so its atexit destructor released aCefBrowserfrom__cxa_finalize_ranges, which is by definition afterCefShutdown():AssertNotShutdownagain, this time with no Dart or plugin frame anywhere in the stack to point at the cause.CloseAllBrowsersnow drops it before its own empty-map early return (a popup takes focus without ever entering the map), andcloseBrowserdrops it when the browser going away is the focused one. It is also allocated once and never destroyed, so no exit-time release exists to get this wrong again.- The fork's event getters (
onToolTip,onCursorChanged,onFocusedNodeChangeMessage, …) carry explicit types; upstream leaves them inferred, which makes themdynamicand silences the analyzer at every call site. Annotating them surfaced a real one:onFocusedNodeChangeMessagewas invoked unguarded while every sibling case used?.call, so a focus change arriving before a WebView registered its callback would throw. Keep the annotations through any rebase — they are what makes that class of bug visible. - Registration codes: the server logs
Registration code for <email>: <code>instead of mailing it.ServerpodProcessControllerreads it from each structured log event's message (no line reassembly — an entry is whole) and exposesregistrationCode;RegistrationCodeToastfloats it over the preview until dismissed or superseded. - Keyboard into CEF (macOS): a local
NSEventmonitor (CefWrapper.processKeyboardEvent) routes keys. It only forwards tocurrent_focused_browser_, which is set fromsetClientFocus— never let it consume an event it can't deliver, or the webview looks dead. - Clipboard: off-screen browsers are not in the responder chain, so Chromium never turns ⌘X/C/V/A/Z into editing commands. They are translated to
CefFrame::Cut/Copy/Paste/…(WebviewHandler::sendEditCommand). Every other ⌘ chord is left uncaptured so the app's menu bar wins. - IME: while a web input is focused, text keys go to the OS input method and come back as
imeCommitText/imeSetComposition; only navigation/control keys are sent raw. Don't reattach the text input client while it is already active — that kills a live composition.
The header's minimize button (a SegmentedIconButton beside the view/theme
controls) strips the shell down to the preview. Minimized means two different
things by view mode, and PreviewModeController.compactWindow is the one
place that policy lives: in a phone mode the window itself becomes the phone
frame — borderless, transparent, moved by dragging the frame's edges and
resized aspect-locked from its corners, with a floating control bar (server
status, view mode,
app theme, expand) below the frame; in desktop mode the toolbar and the
server-log panel are hidden and the window is untouched — minimized is
preview-only in every view mode, and the user's own log toggle is preserved for
the way back. The preview header stays in desktop-minimized, so the same
button — now an expand icon — is the way back.
- View mode, preview brightness and minimized are lifted out of
PreviewPaneStateintoPreviewModeController(preview/preview_mode_controller.dart), created inmain.dart. Minimizing reshapes things that live above the pane:StudioShelldrops its toolbar,ShadAppdrops its opaque background, and the native window goes borderless. The pane listens and reacts exactly as its old local setters did (surface sync on view-mode change, theme shim on brightness change). - Transparency has three layers, and all three must clear. The native
window (
isOpaque = false, clear background), the Flutter surface (FlutterViewController.backgroundColor = .clear— the recipe is documented in FlutterViewController.h), andShadApp, which paints an opaqueColoredBoxbehind every route — itsbackgroundColor:is set to transparent whilecompactWindow, from the samemain.dartlistener. The CEF surface stays backed by the preview backdrop inside the frame, so the page itself never goes see-through. - The compact window keeps the frame's aspect, not the window's. The
chrome —
kCompactFrameMarginaround the frame (room for its drop shadow, which would otherwise clip at the window edge — and the shadow scales with the frame, so the margin carries slack beyond the ~33px resting extent) plus the gap and control bar — is fixed-size, so the contract isheight = chromeHeight + (width − chromeWidth) · aspectH/aspectW.CompactWindowGeometrycarries it over thedev.serverpod.appstudio/windowchannel;MainFlutterWindow.swiftstores it and applies it on corner resizes and orientation flips. A flip carries the frame's scale over, never the window height — carrying the height turned a tall portrait window into a landscape one wider than the screen, with its resize corners off it. Every geometry application clamps the scale so the whole window fits the visible screen. - Move and resize are tracked natively, not with Dart deltas — Dart
gestures would measure against a window that is moving under them. Both run
a local
NSEventmonitor that swallows the drag events until mouse-up. NotperformDrag(with:)— that hands the move to the window server, which enforces the stop-at-the-menu-bar rule itself and never consultsconstrainFrameRect, so the transparent top margin kept the bezel from ever reaching the top of the screen; the monitor'ssetFrameOriginis unconstrained and the only clamp is ours (bezel to the visible top of the display under the mouse, margin past it). Corner names cross the channel in UI (top-left origin) terms; the Swift side flips them for screen coordinates. The new width is start width + mouse travel, never mouse-to-anchor distance — the grab lands tens of points inside the corner handle, and anchor-distance sizing snapped the window smaller by exactly that inset on the first drag event. - The corners' diagonal resize cursor is AppKit's, not Flutter's. The
macOS engine maps no diagonal
SystemMouseCursors(they fall back to the arrow), so corner hover asks the native side forNSCursor.frameResize(position:directions:)(macOS 15+; crosshair before). The call is deferred a microtask so it lands after the framework's ownactivateSystemCursorfor that pointer update, which would otherwise paint the arrow over it — and nothing resets it: the framework's next cursor change is the reset. - Entering compact saves the window state (
CompactSavedState) and exiting restores it — frame, style mask, min size, opacity, shadow, both background colors.canBecomeKey/canBecomeMainare overridden totrueonMainFlutterWindow: a borderless window otherwise refuses key status and the preview would stop taking keyboard input. The window's own shadow is off in compact — the frame paints its own, and AppKit would hug a second one around whatever pixels happen to be opaque. - The pane keeps its
GlobalKeyacross the re-parent, so the CEF webview survives the layout swap instead of reloading. The compact tree builds nothing but the pane — anything else in the shell would paint onto the transparent window. - A compact window never outlives its preview:
main.dart's_onSessionChangedcallspreviewMode.expand()(a no-op when not minimized) before the project notices, so closing or switching projects always comes back to the full window. Native calls are fire-and-forget with a logged catch — a window that failed to go transparent is cosmetic, not fatal. - Any route pushed over the compact window expands it first
(
CompactDialogYield, aNavigatorObserveronShadApp). Dialogs are sized for the full shell — they overflow the phone-sized window, some breaking its layout — and their blurred barrier has nothing to blur on a transparent window. The menu bar stays fully live in compact mode, so this cannot be prevented at the sources; the navigator is the one funnel. The expand is deferred a microtask (observers run inside the navigator's mutation) and still lands before the route's first frame. Deliberately one-way: re-minimizing on close collides with everything else that pops routes (project switches clear the stack), and one click re-minimizes. constrainFrameRectis overridden while compact to allow exactly the transparent margin past the visible top. AppKit stops a dragged window's top at the menu bar, and the compact window's topkCompactFrameMarginis shadow room — without the override the visible bezel stopped that far short of the top of the screen.test/preview/preview_mode_controller_test.dartpins the mode transitions againstFakeCompactWindow(test/support/fake_compact_window.dart), including the two subtle ones: compact → desktop restores the window but stays minimized, and desktop-minimized → phone re-enters compact.
lib/analytics/ sends app_studio.* events to the same PostHog project the
Serverpod CLIs report to — one key (kPostHogProjectApiKey, the phc_… the
serverpod_cli and serverpod_cloud_cli binaries carry), the EU host, and
the same body cli_tools' PostHogAnalytics posts. It is a ~40-line client
on the app's existing http dependency rather than a dependency on
cli_tools, which would pull in args, config and pub_api_client for one
class.
- Three events, one method each on
AppAnalytics, so a property set is written once:app_opened(frommain(), withtool_sourcesand whether setup is required),project_created(fromProjectCreator.create, success or not, with thefailure_stage—validation,cliorconfigure_preview) andproject_opened(from_onSessionChanged, so the picker, Open Recent and a fresh creation all count). - Every event carries the launch context:
$lib: app_studio,$lib_version/app_version/app_build(fromUpdateState, which already readsInfo.plist— nopackage_info_plus),platformspelledMacOSexactly ascli_toolsspells it so one PostHog filter covers the CLIs and the app,os_version,dart_versionandis_debug. - The distinct id is
~/.serverpod/uuid, shared with the CLIs. Read if present, created in the same v4 format if not, so one person is one user across all three tools. The uninstaller removes~/.serverpod, so a fresh install gets a fresh id — which is also what the CLIs do. Unwritable →'unknown', never "no event". - Debug and profile builds send nothing, so
make startruns do not pollute the project — the CLI gates onproductionModefor the same reason.APPSTUDIO_ANALYTICS=1in the environment overrides that; it is how a change here is verified end to end. - Fire-and-forget, never awaited on a user path.
capturereturns at once, sends are swallowed on failure and time out at 2 s, and nothing flushes on quit — a quit must not wait on the network.flush()exists for tests. - Call sites use the
appAnalyticsglobal, which is a no-op untilmain()installs one — the same shape as the CLI'scliAnalytics. That keepsProjectCreatorfree of an analytics parameter and means a test that constructs one sends nothing.test/support/recording_analytics.dartinstalls a recording sink for one test and puts the disabled one back. - No opt-out UI yet. The CLI has
--no-analytics; a Settings toggle is the obvious next step if one is wanted.
- One dialog, two tabs (
ui/settings/settings_dialog.dart): Providers and Tools. They were separate dialogs behind unrelated menus. Each pane owns no chrome — no title, no Done button; the dialog supplies both, including the Tools install button, which needstoolchainsstate the pane no longer has to expose. - With
kOpenCodeAgentEnabledoff it is one pane and no tab strip. Providers configures keys for that agent and nothing else, so a single remaining tab would be a button that does nothing._tabcoerces a caller asking for Providers onto Tools rather than trusting it: the App menu's Settings item still asks for Providers, and that pane would sit on its "waiting for the agent" spinner forever, since no agent is coming. - The pane box is a fixed height, not a maximum. Both panes scroll
internally, so sizing to content made the window jump every time a tab
changed, a provider was added or a tool finished installing. Whitespace under
a short pane is the cheaper trade;
_kPaneHeightis the one knob. - Settings starts the agent on a scratch directory when no project is open (
AgentController.startForSettings, called fromStudioShell._showSettings) — with the agent switched off this returns immediately, since there is no Providers tab for a scratch serve to answer. Keys and the default model are configured here and both are read from the running agent, so the pane would otherwise be unusable until a project existed.opencode serveneeds a working directory, so it gets<appSupportRoot>/agent-scratch— a real path it can drop its own state into. Closing Settings callsstopIfStartedForSettings, which only stops a serve it started: with a project open the whole thing is a no-op, and a live session is never torn down. Opening a project clears the flag, so a project that appears while Settings is open supersedes the scratch serve rather than being killed with it. - The Providers pane waits for the agent rather than rendering an empty list. Until
ProvidersView.agentReady, "which providers hold a key" and "which models exist" are unknown, not empty — showing the list anyway marks every provider disconnected and invites the user to re-enter keys they already saved._WaitingForAgentstands in, and the pane reloads models on the edge into ready. Its spinner animates forever, so any test touching this pane must use fixed pumps, neverpumpAndSettle. ManageToolsPanedefers its deep probe to a post-frame callback.ToolchainController.refresh()callsnotifyListeners()synchronously before its first await, so starting it ininitStatemarks any ancestor listening to the controller dirty mid-build. Harmless while it was a standalone dialog; a crash the moment the Settings dialog wrapped it.- Uninstall lives in the App Studio menu, not the Tools tab — it is not a
tool operation. App Studio's own data always goes: the Application Support
directory, whatever an earlier build left for tools (its private toolchain
and fenced
PATHblock), and preferences (NSUserDefaults), which is what makes a relaunch behave like a fresh install. - The tools App Studio installed are checkboxes, ticked. They are real
installs now, and other projects or the user's own terminal may rely on
them, so they are a choice rather than implied. Only the command line tools
carry a line on what removing them affects (
uninstallConsequence): Flutter and Serverpod say nothing beyond their label, by request — the dialog is meant to be short. Only tools in the install record that are still on disk are offered (removableTools); a tool the user installed themselves is never listed. - Removing Flutter forces Serverpod (
kUninstallRequires): the CLI runs on Flutter's Dart. Its box is ticked and disabled while Flutter's is, and says why. The command line tools are not forced either way. - The Dart package cache goes with Flutter, and only with Flutter
(
UninstallChoice.clearPubCache). There is no checkbox for it.dart pub cache cleandoes it while Flutter's Dart is still there; without a Dart,~/.pub-cacheis deleted directly — built fromToolchainPaths.home, never from$PUB_CACHE, so a test's scratch home cannot reach a real cache. - Order matters, and it is Serverpod → pub cache → Flutter → CLT.
dart uninstallanddart pub cache cleanboth need Flutter's Dart. The CLT go throughosascript … with administrator privilegesrunning Apple's documentedrm -rf /Library/Developer/CommandLineTools; a full Xcode is never touched, and cancelling the password prompt is reported as a failure. - Each
PATHline goes exactly as recorded.withoutLineremoves lines equal to the recorded one and touches nothing else. Dart's line stays while anything else is still installed withdart install— that directory is shared. - It never aborts on a failed step: stopping halfway leaves neither a working install nor a clean machine. Failures are collected and named on the final screen, which never claims a clean uninstall that did not happen.
~/.serverpodgoes with the Serverpod CLI, and only then. The CLI keeps its own state there — template caches, the analytics id, the cloud CLI's login — and so does the install record. When the CLI goes, the directory goes, andpersistRecord()writes back whatever App Studio installed that the user chose to keep, so it can still be offered next time.- Deliberately not removed: anything App Studio did not install, and
~/.local/share/opencode/auth.json(API keys the OpenCode CLI uses independently of App Studio). - Both uninstall screens ride on
LandingScaffold, like the start page and first-run setup: with no project, no toolchain and no session left, the branded gradient is the one surface here that does not depend on something that was just deleted. That means the fixed dark landing theme, so every colour on them is set explicitly — a text role with no colour of its own falls through to theDefaultTextStyleShadAppinstalled from the app theme, and lands as black text on the gradient in light mode.test/uninstall/uninstalled_screen_test.dartpins that in both brightnesses. - The final screen is a dead end by design. The session is stopped, the toolchain is gone and preferences are cleared, so any control would either fail or silently recreate what was just removed.
make widgetbook # every screen and dialog, in every state, light and dark- It is for reviewing copy and layout without reaching each state by hand —
a clean Mac, a failed install, Apple's installer, an old Flutter, a partly
created project. The catalog (
widgetbook/catalog.dart) is ordered the way a user meets things: First run → Start → Open project → Settings → Uninstall, then the shared Components. - It is a second entry point into this package, not an app of its own
(
flutter run -d macos -t widgetbook/main.dart). It runs in App Studio's own macOS runner, so the fonts, theassets/images and every plugin are the real ones — andwebview_cefneeds no second CEF setup.widgetbookis a dev dependency and nothing underlib/imports it, so release builds do not carry it. It replaced Flutter's widget previews, which only ran on the web: anything that reacheddart:iocould not be shown, which is why they covered first-run setup and nothing else. - Every use case is hosted in the app's own
ShadApp(widgetbook/host.dart,UseCaseHost), keyed by the Widgetbook URL — use case and theme — so each view starts fresh. The theme switch is Widgetbook's Theme addon; there are no knobs, because every state worth reviewing is its own use case. - One use case is one screen or one dialog, in one state — never a flow.
DialogUseCasedraws the dialog in place over a realistic backdrop (landingBackdropfor first-run dialogs,StudioWindowMockfor anything shown with a project open) behind the theme's barrier colour. Nothing is pushed, so nothing sits over Widgetbook's controls. An earlier version drove the realshow…functions and a simulated install; it opened dialog after dialog on top of Widgetbook and was replaced for exactly that. States that sit behind a button are reached with a constructor parameter instead —NewProjectDialog.createOnOpenis the one that exists. - The use case lives on a page above a "Closed." page. A dialog's Cancel or
Done pops it, as in the app, and leaves Show again rather than an empty
navigator. Quit is the exception: it is
exit(0), and closes Widgetbook. - Every dialog goes through
showAppDialog(widgets/app_dialog.dart), nevershowShadDialogdirectly. shad defaults to the root navigator, which in the app is the only one but in Widgetbook is Widgetbook's: a dialog opened from inside a use case — the toolbar's Publish to Cloud, say — would cover Widgetbook's own UI and outlive the use case that opened it, which surfaces as a cascade ofDuplicate GlobalKeyassertions. - Screens and dialogs are drawn at a real window size and scaled down
(
AppWindow,kAppWindowSize1280×800), withMediaQuerysaying so. Widgetbook's canvas is narrower than App Studio's 670×720 minimum, so laid out at canvas size the toolbar overflowed, and shad — which switches dialogs to a phone layout below a 640-wide screen — centred dialog text and stacked the buttons. Neither ever happens in the app. The catalog test also builds the studio window at exactly the minimum (kMinAppWindowSize), which is the overflow a real user could hit. StudioWindowMockis an open project assembled from the real parts —StudioToolbar,LeftPaneHeader,ServerLogView,PreviewHeaderBar,PreviewStatusOverlay,RegistrationCodeToast. The log is the real view fed a sampleRunnerLog(widgetbook/fakes/runner.dart— the tests use the same one); the preview is a placeholder where CEF would draw the app. As a use case of its own it is live — hover shows tooltips, the toolbar's dialogs open inside the canvas; as a dialog's backdrop it ignores the pointer.- The widgets take narrow interfaces so they can be faked, the pattern
ToolsViewstarted:RecentProjectsView(start page),NewProjectView(New Project — the folder picker,serverpod create, opening the result),FullRestartView,HotRestartView,ExternalEditorsView,ProjectReadyView,ProvidersView. The real controllers implement them directly. Anything that was private only for tidiness and is worth reviewing on its own — the toolbar, the pane headers, the uninstalling screen — is public in its own file. - The fakes live in
widgetbook/fakes/and the widget tests import them from there too, so the states Widgetbook shows and the states the tests pin are the same objects. test/widgetbook/catalog_test.dartbuilds every use case in both themes inside the same host, under an outer navigator standing in for Widgetbook's. A use case that throws on build, overflows at desktop size, closes itself, or pushes anything onto that outer navigator fails there rather than the next time someone opens Widgetbook. Add new screens to the catalog; the test picks them up.- Not covered: the agent pane (switched off, and it takes a concrete
AgentController), a live runner and CEF itself. Their surroundings are.
- Long-lived controllers created in
main.dart:ThemeController,ProjectSession,ServerpodProcessController(which owns theRunnerLog),AgentController. teardownSessiontakes every controller that owns a child process, and the parameters are required on purpose. It is the one function both the quit path and Sparkle'sprepareForRelaunchcall, so anything optional there is a stray process waiting to happen — a runner of ours left up when Sparkle swaps the bundle.ServerpodProcessControllerowns the runner's lifecycle and the attach, and signals when the preview is ready to load.- The menu bar lives above
ShadApp, so menu-triggered dialogs use thenavigatorKeycontext frommain.dart. - Swapping
homedoes not clear what is pushed over it — call_clearPushedRoutes().homeis the first route, so a screen swap happens underneath any open route and is simply invisible: uninstalling with the New Project screen up left the user filling in a form for an app that had just deleted itself. The menu bar stays live behind every dialog and every full-screen route, so this is reachable from all three transitions that replace the screen, and all three call it — uninstall, the toolchain flip that swaps setup for the shell, and a project opening (⌘O and Open Recent work while New Project is up, and the project would otherwise load behind it).Navigator.popignoresPopScope, which is what lets this clear a screen the user may not dismiss;test/new_project_dialog_test.dartpins that. - The toolchain caller defers to a post-frame callback and the other two do not.
ToolchainSetupDialogcloses itself on the sameToolchainControllernotification and is a later listener, so popping synchronously there would land first and leave its ownNavigator.popto hithome, emptying the navigator. - A key Flutter reports as handled never reaches the menu bar, so a focused widget that swallows keys takes every menu shortcut with it. The old xterm server log did exactly that — ⌘W arrived at the server as a
w. The log is aSelectionAreaover a list now (ui/server_log/server_log_view.dart), which answers ⌘C and ⌘A and lets everything else through; the search field is an ordinary text field.test/server_log_view_test.dartpins ⌘W and plain typing passing through, and copy working. MacAppMenu.install()callsMacMenuBar.reset()first, and must keep doing so. A hot restart replaces the Dart isolate but not the engine, its plugins or the nativeNSMenu, so without it every menu is added again on each restart — and_recentItemIdscomes back empty, so Open Recent duplicates too.reset()removes exactly the items this plugin added: those are the ones carrying a StringrepresentedObject, which is also how the plugin finds items by id, so stock MainMenu.xib entries cannot be stripped.- Never return
menuItem.isEnabledfromvalidateMenuItemfor an item the plugin overrode. Under automatic enabling AppKit writesisEnabledfrom the previous validation pass, so reading it back is a latch, not a query. MainMenu.xib shipsPreferences…with no action at all, so AppKit disabled it before the override was installed and it could never re-enable — the Settings item was permanently greyed out. Overridden standard items are tracked inoverriddenItemsand validate totrue; the Dart handler decides what happens when they fire.
main.dart picks the whole screen from toolchains.inventory.needsSetup:
true means ToolchainSetupScreen, false means StudioShell. The start
page and the setup flow are two screens, never stacked. The dialog used to
float over the start page, which offered New Project and Open Project buttons
that could not work — there is no Flutter SDK to build with yet.
- The gate is
ToolsView.needsSetup: something App Studio can install or update, a Flutter the user has to update themselves, an earlier build's toolchain still to remove (allToolchainInventory.needsSetup), or the one-time Puro/FVM notice still unanswered. It is named rather than written out, because three things have to agree on it and would otherwise drift: the screenmain.dartpicks,MacAppMenu.canOpenProjects— ⌘O, Open Recent and Settings would walk straight past the gate — andStudioShell._showSettings. - Settings is disabled and inert while setup is up, which is two mechanisms
on purpose. The menu item greys out, and
_showSettingsreturns without opening anything. The shell is not even built in that state, so the second is belt to the first's braces — but every route into the dialog goes through that one function (the menu item viaAgentController's handler, and the toolbar's warning badge), and the only pane Settings could show then is a Tools tab duplicating the setup screen already on top of it. MacMenuBar.setSettingsEnabledis pushed before the menus are installed, not after. The plugin installs its Settings override from a block queued at registration — which force-enables the item — and its own flag defaults to enabled, so until Dart says otherwise the stock item is live and ⌘, reaches the action without the menu ever being drawn. The native side re-applies whatever it was last told when the override lands, so pushing early is safe either way round.- Disabling the stock Settings item sets
isEnabled, notvalidateMenuItem.addMenuItemturnsautoenablesItemsoff on whatever menu it adds to, and this app adds Check for Updates and Uninstall to the App menu — so AppKit stops validating that whole menu andvalidateMenuItemis never consulted for anything in it.MacMenuBar.setSettingsEnabledtherefore writesisEnableddirectly, the same mechanism the plugin's own items use. It cannot reusesetMenuItemEnabled: that is keyed by the StringrepresentedObjectstamped on items the plugin added, andreset()deletes exactly those, so giving a MainMenu.xib item one would make reset strip it. - The plugin re-applies the flag when it installs the override, because that runs from a block queued at registration and can land either side of the first channel call — and the override force-enables the item.
- Both screens share one backdrop.
ui/landing_scaffold.dartowns the black logo banner and the gradient;StartPageandToolchainSetupScreensupply only what sits on top. They are two states of the same moment — the app open with no project — so a different backdrop on each read as a different app. It is its own file rather than something exported fromstart_page.dartbecause that one importsProjectSessionand so drags indart:io. - Everything over that backdrop runs on
buildLandingTheme(), in both system brightnesses.LandingScaffoldwraps its child in it, so the landing screens never read the system's light/dark setting: the image behind them does not change either way, and light chrome on it would be dark text on a photograph. Dialogs raised from these screens areNavigatorroutes and so are not covered — they sit above the scaffold and keep the app's theme. - The backdrop is drawn, not loaded.
_LandingBackgroundis one verticalLinearGradient— black at the banner, through navy and indigo, to orchid at the floor. It replaced a 19 KB WebP that had to be decoded and rescaled on every resize and letterboxed by hand to keep its aspect ratio. Retune the whole backdrop by editing that one stop list. The stops bunch through the middle deliberately: spread evenly, the indigo-to-purple leg bands visibly down a tall window. - Both landing surfaces are white glass, and that is downstream of the
gradient. The action cards (
kLandingCardSurface) sit in the middle of it; the recents panel (kLandingPanelSurface/kLandingPanelBorder) is thinner so the deepest part still reads through. This only works because the drawn backdrop stays dark at the bottom — the photograph it replaced washed out to pale pink there, where a white scrim lightens an already-light surface, and the panel had to be black glass with a darker edge to survive it. Restore a light-bottomed backdrop and that inversion comes back with it. Everything with an obvious slot rides in the scheme (primary,card,accent,border,mutedForeground); the panel is the exception, having no token of its own. - Both landing actions are the same pink (
kLandingActionColor, pink-600). Creating and opening are the two ways in, not a choice with a recommended answer, so neither gets primary/secondary weighting — and the app's brand blue is a poor neighbour to the blues this gradient starts in. - Nothing over the backdrop may leave a text colour unset. A shad text role
with no colour falls through to the
DefaultTextStyleShadAppinstalled from the app theme, which is the one thing on these screens that still follows the system's light/dark setting. A recent project's path was set in baretextTheme.mutedand so went black in light mode while every explicitly coloured string beside it stayed white.kLandingSubduedForegroundis the translucent white for type that should recede. - A card nested around a control needs
concentricRadius, not the same radius as the control. The two action buttons sit in 10px of card; sharing one radius makes the visible ring of card measurably fatter at the corners than along the edges. - The setup spinner is explicitly
Colors.white. The backdrop is a fixed brand image in both themes and the spinner sits over its saturated middle, where the default (primary, brand blue) all but disappears — the same reason the banner hardcodes black. - Setup is a sequence of dialogs over a spinner (
_drive()intoolchain_setup_screen.dart), each blocking on the user so the loop cannot spin: the Puro/FVM notice, then the legacy notice and cleanup, then the "update your Flutter" prompt, then the plan. - The Puro/FVM notice (
FlutterInstallNotice) is asked once, on a first launch without a standard Flutter. App Studio does not work with a Flutter managed by Puro or FVM yet, and the notice points those users at Serverpod's Quick Start instead; Quit or Continue. "Standard" isToolchainInventory.standardFlutterFound: a Flutter found outsidefvm,.fvmand.puropath segments — both managers resolve their symlinks into their own folders.ToolchainController.createrecords the question as answered (setup.flutter_notice_seen) when a standard Flutter is there, so removing one later does not bring it back; Continue records it too. Quit does not, so the next launch asks again. Uninstall clears preferences, so a reinstall is a first launch. The Flutter prompt comes before the plan because the Serverpod CLI is installed with that Flutter'sdart. - The plan is not raised until
ToolsView.preflight()has answered. Every version in it comes from Flutter's release feed and pub.dev; raising the dialog first and filling the table in afterwards is what made a slow network read as a stuck dialog. - The plan says where everything goes. It edits the user's machine outside
the app, so the description names
~/develop/flutter,dart install, Apple's installer and the shell profile, and each row says how its tool arrives (a size, "From Apple", "dart install", "flutter upgrade"). - Failure raises
ToolchainSetupErrorDialog— the message plus Try again, which loops back, and Quit. There is no third option: the app cannot build a Serverpod project without these tools. - Both dialogs are
canPop: false. The plan dialog closes itself when the inventory comes back complete, which is why that usesNavigator.popand notmaybePop—maybePophonours the samePopScopethat refuses the user. _drive()re-checksinstallableafter the preflight. The user can install a tool in a terminal meanwhile, and the plan dialog only self-closes once an install has run, so an empty one would strand a modal over the shell.- An install runs to the end without ever going back to the plan. There is
real work after the last step — a deep re-probe — and showing the plan again
through it read as the install having been thrown away, right before the
dialog closed.
ToolchainController._finalizingfolds intoisInstallingand covers it; the dialog's own_runningcovers the rest ofinstallMissing()._closeIfCompletedefers to_running, so the self-close fires once, from_install'sfinally. - Progress is per step, not one bar. Only the Flutter download can measure
itself; Apple's installer,
flutter upgradeanddart installreport nothing. So the bar is determinate while downloading and indeterminate otherwise, and the caption says "Step 2 of 3" plus either the byte count or what the step is doing (ToolPhase.hint) — for Apple's installer, that the user has to click through its window. - The dialog is closed from outside, and its self-close checks for that.
Because it now outlives the moment
installablegoes empty,main.dart's post-frame_clearPushedRoutes()is what actually takes it off on a first run. ANavigator.popafterwards popshomeinstead and leaves an empty navigator — a white window, with the start page's page transition playing out as it goes, which is precisely the failure the comment in_onToolchainsChangedhas always warned about._closeIfCompletetherefore pops only while its own routeisCurrent. The window is one frame wide (the route is out of the history, theStateis not yet unmounted) and the end of the install can land right in it;test/toolchain/toolchain_setup_dialog_test.dartreproduces it withremoveRouteand fails without the check. - Past the last step there is nothing left to cancel, so Cancel dims rather
than disappearing — in the setup dialog and in the Tools tab's banner.
Cancel stops between steps; it cannot stop Apple's installer, which is its
own app. The line over the bar comes from
InstallProgress.statusLabel, which names the step in progress and calls the tail "Finishing up…".
- File → New Project with a project open closes it first (as Close does),
then opens the screen — a frame later, because
_onSessionChangedclears pushed routes a frame after a project closes and would pop it. The item is enabled whenever Open is; it used to be added disabled and never enabled. - New Project (start page + File menu) opens
ui/new_project_dialog.dart: project name + location + external AI editors, thenProjectCreator(process/project_creator.dart) runs the bundledserverpod create <name> --ide … --no-interactivein that location. - It is a screen, not a dialog over one.
showNewProjectScreenpushes a route whoseNewProjectScreendraws the form onLandingScaffold— the same backdrop and fixed dark theme the start page uses, so arriving here does not read as a different app. There is no barrier, so nothing to blur and nothing to click through: a stray click beside a form holding a typed name used to throw it away. Cancel pops back to wherever it was opened from, which is the start page in the common case and the shell when it came from the File menu. - The form itself is still a
ShadDialog.alert(NewProjectDialog), which is the variant with no close X — Cancel is the way out, and mid-run there is none. It is centred outside itsSingleChildScrollView: a scroll view sized to its viewport pins a short child to the top. - The form is put back on the app's theme, with a
ShadThemeread aboveLandingScaffoldand reinstalled inside it. The landing theme is fixed dark because the gradient is, which is right for chrome sitting on that backdrop and wrong for a surface with its own background — a light app would otherwise show one dark panel here. Routes raised from the start page already behave this way by landing outside the scaffold; this one is inside it and has to say so. - Every
--idevalue is anIdeIntegration, and exactly one is picked — aShadRadioGroupof_IdeRadios, three to a row.--ideis still a multi-option and the pick still goes out as its own flag; passing any value at all replaces the CLI's own defaults (claude, cursor, vscode), so what is picked is what is configured and nothing else. - Picking one is mandatory, and Create is what says so.
_canCreatewaits on the editor as well as the name. This replaced a set of checkboxes that could all be left clear plus a "No external editor selected" prompt (Continue / Pick Editor) raised after the user had committed — a control that cannot express the empty answer says the same thing up front and needs no prompt._hasExternalEditorand_confirmNoExternalEditorare gone with it. builtInstill exists and nothing sets it any more. It meant "configured whether or not it was asked for", and OpenCode was the only one, because App Studio ran it as its own agent. WithkOpenCodeAgentEnabledoff there is no Agent tab, soIdeIntegration.opencodetakesbuiltIn: kOpenCodeAgentEnabledand a new project gets exactly what was picked.configuredIdes()is consequently an identity function today; it is kept because the flag can come back. Do not mark OpenCode's option specially — a checked-and-disabled box was tried and deliberately reverted.- Names must match
^[a-z][a-z0-9_]*$(they become Dart package names); the input folds case and drops separators as you type. - After the CLI succeeds,
patchFlutterAppForPreviewsetsdevice: web-serveron theflutter_appsentry in<name>_server/pubspec.yaml— without it the runner launches the app onweb-server-launch-browser, which also opens the system browser. No port: the runner publishes the URL. The project is then opened, which auto-starts it. - Last used location is remembered in
ProjectSession(new_project_directorypref). IdeIntegrationlives inprocess/ide_integration.dart, not in the creator, purely soProjectSettingscan use it without draggingdart:ioin.project_creator.dartre-exports it, so every existing import still reads naturally.configuredIdes()is the one place "which editors got configured" is computed, shared by the--ideflags and the settings write below. Two lists of the same thing is how the record drifts from the reality.
ProjectCreator writes <project>/.appstudio/settings.json after the CLI
succeeds (session/project_settings.dart). It records which editors the user
picked, which is what the toolbar's Open in … buttons are drawn from.
-
Picked, not configured — the distinction is the whole point of the file.
configuredIdesadds thebuiltInentries whether or not they were asked for, so writing that set down would put an Open in OpenCode button on every project anyone ever created.recordProjectSettingstakes the raw selection andtest/project_creator_test.dartpins it. -
It is project state, not preferences. Preferences are global and live in
SharedPreferences; this belongs to the project, so a copy of it carries the answer and a second machine reads the same one. -
A failed write never fails the creation. The project is complete and usable without it, and losing a finished project to an unwritable dotfile would be a far worse trade than losing two buttons.
-
A missing or corrupt file falls back to the config files the CLI itself wrote —
.cursor/mcp.json,.vscode/mcp.json,.mcp.json,.codex/config.toml,.agents/plugins/serverpod-local/mcp_config.json— mirroring the CLI's ownTemplateIde.filePath. Without it the feature would do nothing for any project that predates it. Each path is specific enough that its presence really does mean--ideran: half the projects on a Mac have a.vscode/, but.vscode/mcp.jsonis ours. -
Inference skips
opencode.json, which is listed in that map and never read. It is in every project App Studio has created up to now, picked or not, so it carries no signal about the choice — inferring from it would reinstate the button the picked/configured split exists to keep off. -
_neverInferredis a list of its own, not a read ofbuiltIn. It used to be the latter, which was the same set right up untilIdeIntegration.opencodestopped being built in — at which point every project already on disk would have grown an "Open in OpenCode" button. The projects do not change when the flag does, so neither does this. -
A file that parses wins even when it lists nothing. Ticking no editor is a real choice, and the config the CLI left behind must not be read as one.
-
An id the running build does not know is dropped without taking the rest of the list with it — that is a newer App Studio's editor, not a corrupt file.
- Opening one auto-starts the server (
ServerpodProcessController._onSessionChanged), which compiles the whole stack before anything reaches the preview.ui/project_startup_dialog.dartsays so up front, because the first minute or two is otherwise indistinguishable from a hang. - It is triggered in
main.dartfrom aProjectSessionlistener that tracks the project path, nothasProject— switching straight from one project to another recompiles just as much, and the boolean never changes across that. The dialog is pushed from a post-frame callback: the session can notify mid-build, and pushing a route from inside a build is illegal. - "Don't show this again" is written to
project.startup_notice_dismissedonly when the box is ticked, and by the caller — the widget just pops the bool, which keeps itdart:io-free and easy to show in Widgetbook. This is a one-way door, sotest/project_startup_dialog_test.dartcovers both directions: OK alone must leave the pref unset. - The dialog is
barrierDismissible: false. With the confirm button as the only action, a stray click on the barrier would otherwise silently discard a ticked checkbox. - It styles nothing itself. Two shad details make that possible, and both generalise:
ShadDialog.alerthas no close X;ShadDialog(primary) does. The only difference is thatprimaryDialogThemesetscloseIconDataandalertDialogThemedoes not, and there is no flag to turn it off — so pick the variant, don't fight it. Alert is also the right semantics for a notice you acknowledge rather than dismiss.- Body copy belongs in
description, not achildwithtextTheme.small.smallis shadcn's label role —w500atheight: 1.0(font-medium leading-none) — so paragraphs set in it read bold and cramped.descriptionresolves tostyle.bodyovertextTheme.muted:w400atheight: 1.43.
- The checkbox sits in
actionsbeside the confirm button. The actions row defaults toMainAxisSize.min/MainAxisAlignment.end, sospaceBetweendoes nothing on its own — it needsactionsMainAxisSize: MainAxisSize.maxtoo.
Opening a project raises "Starting your project" while it compiles, then
"Your project has started" (ui/project_started_dialog.dart) once
PreviewPhase.ready lands. main.dart's _showProjectNotices runs them one
after the other.
- The startup notice closes itself when the project comes up, carrying its
ticked checkbox with it. That is also what makes the sequencing work: awaiting
its future is how the second notice waits out a user who left the first one
sitting there. Showing the second on a timer instead would land it on top of
the first. The self-close is guarded on
ModalRoute.isCurrent— popping a route that has already gone takeshomewith it and empties the navigator. - Its second paragraph ("When the app has started, open this project in your AI editor…") is gone: that is the whole job of the notice that now follows it, and saying it during a two-minute compile is advice the user cannot act on.
- The started notice names one editor and offers one button —
featuredEditor, the first ofExternalEditorsView.editors. A row of equally weighted buttons is a decision; this is a next step. The toolbar carries the full set. With nothing openable the copy falls back to "your editor" and there is no button: naming an editor we cannot launch is an offer we can't keep. - The Cursor tip carries a screenshot (
assets/cursor-setup.png), because the MCP toggles it points at live behind a search field that is hard to describe in a sentence.Image.assetneedswidth: double.infinity— the asset is 842px wide and would otherwise ask for 842 logical pixels and blow the dialog open. - The actions row wraps its checkbox in
Flexible. This one carries two buttons and the wider is named after whatever editor turns up, so unlike the startup notice it can overflow. Note thatflutter_test's font makes every glyph a full em, so text there is roughly twice its real width — a row that is fine in the app can still overflow in a test, and the fix is to make the row survive it rather than to widen the dialog until the test passes. - Both preferences are one-way doors and both are written by the caller, not the
widget:
project.startup_notice_dismissedandproject.started_notice_dismissed. Opening the editor is the caller's job for the same reason — it keeps the dialogs clear ofdart:io.
The Dart MCP server (dart mcp-server) is configured for this repo in
.mcp.json (Claude Code) and .cursor/mcp.json (Cursor). It connects to a
running debug build and can inspect it, read its errors, hot reload it and —
with Flutter Driver's extension on — tap, type and take screenshots.
Use it to check UI and behaviour changes in the running app, not only in
widget tests. Tests stay the gate; this is how you look at the result.
Launch a driver build, in the background, then attach:
make widgetbook_driverfor a screen or dialog;make start_driveronly for a flow Widgetbook cannot show. Both areflutter runwith the driver extension (test_driver/). Run it in the background and wait forFlutter run key commandsin its output — the first build takes minutes.- Bring the window to the front, and again whenever you come back to it:
osascript -e 'tell application "System Events" to set frontmost of (first process whose unix id is <pid>) to true', with the pid frompgrep -f 'Debug/App Studio.app/Contents/MacOS/App Studio'. A window behind others stops drawing frames: screenshots come back stale — the launch screen, long after you navigated away — and frame-synced commands time out.flutter runitself reportsFailed to foreground app. dtd→listDtdUris, pick the instance whose workspace root is this repo (not the IDE's, rooted at/),connect. Thenflutter_driver_command→get_healthshould answerok.get_runtime_errorsfirst, and again after every step.widget_inspector→get_widget_treewithsummaryOnly: truebefore any driver command. Never guess a finder: use the text, tooltip or widget type the tree actually shows. The tree is 200 KB of JSON; search it (textPreviewholds a widget's text) rather than reading it whole. Hint text is not in it — a text field is foundByType.waitForwith a shorttimeout(3000) before everytap, and only tap what it found. A tap whose finder never matches does not end when the MCP call times out: it keeps waiting inside the app, and every later tap fails withGuarded function conflict.hot_restartis the only way out of that;get_healthworks again a moment after it.flutter_driver_command→tap(ByText,ByTooltipMessage,ByType), thenscreenshottwice and trust the second — on macOS the first returns the frame before your action.enter_textneedsset_text_entry_emulationwithenabled: truefirst — it is off at launch so a human can still type in the window — andByTextthen also matches the text you typed into the field.- After editing code:
hot_reload, orhot_restartfor changes tomain(), globals or consts. Thenget_runtime_errorsagain. - Stop the
flutter runwhen done (q, or kill it). Do not leave it running.
In Widgetbook, reach a use case through the Search field, not by
scrolling the tree: the tree is a lazy list, so an item below the fold does
not exist until it is scrolled to, and a finder for it just waits. Tap
ByType SearchField, enter_text a word from the use case's name — a
prefix like Runnin, so the field's own text does not also match the name —
tap the component to expand it, then waitFor and tap the use case. The theme
switch is the Theme addon in the right-hand panel.
Rules — each is about something that happens outside the window:
- Never launch while the user has their own
flutter runof this app going. Every run builds into the samebuild/macosbundle, so a second one replaces the app they are using. Themakerun targets refuse on their own (refuse_second_run: anyflutter runwhose working directory is this repo) — so launch through them, never with a bareflutter run, and treat their refusal as final. Attach to theirs instead: the inspector, runtime errors and hot reload all work over DTD, but driver commands do not — a plainmake starthas no driver extension. Ask before stopping their run. - Prefer Widgetbook to the real app. The real app acts on the machine:
main()reclaims what the dev-session files name (it will stop a runner another App Studio session started), opening a project starts a Serverpod runner or attaches to the user's own, the setup screen installs the Command Line Tools and Flutter and edits~/.zprofile, and Uninstall deletes tools. Do not press Install, Uninstall or the New Project form's Create in the real app without asking. - Quit is
exit(0)everywhere, Widgetbook included — pressing it ends the session you are driving.
- After a UI change, look at it running — Widgetbook through the Dart MCP server (see Testing with the Dart MCP server) — as well as running the tests.
- Keep diffs small; match existing naming and file placement.
- Reuse
shadcomponents and cloudui patterns before inventing new ones. - Only commit or open PRs when explicitly asked.