harde1/dsh-xcodebuild

dsh-xcodebuild

在 harness 中通过 xcodebuild 编译、测试、归档并运行 iOS 或 macOS 工程——实时可过滤的构建日志、从工程自身读取的 scheme 与目标设备选择,以及模拟器与 USB 真机的日志流,包含经 libimobiledevice 支持的 iOS 16 及更早设备,其 app 日志在设备锁屏时依然可读。仅限 macOS。

待分类

XcBuild

An Xcode build and development loop for DeepSeek Harness: build, test, clean, archive and run an iOS/macOS project, with a live build log you can filter — driven either by the agent through six tools, or by hand from a panel inside the conversation.

Inspired by the SweetPad VS Code extension. The package and composition row keep the id dsh-xcodebuild; XcBuild is the name shown in the UI.

What it adds

Seven model-facing tools

ToolPurpose
xcode_doctorWhat this machine has and what it lacks, with the install command for each gap, plus the Xcode in use. Run this first when a device will not install or launch.
xcode_projectDetect a .xcworkspace / .xcodeproj / Package.swift; list schemes, configurations, targets, and any SweetPad defaults in .vscode/settings.json.
xcode_destinationsEvery channel that can name a device, merged: -showdestinations (simulators, My Mac, the hardware Xcode manages) + xcdevice (Xcode's own device layer) + devicectl (CoreDevice) + the classic USB channel. Each entry carries a ready-to-use destination string, whether it is reachable, and which sources saw it; recommended is never an unreachable device.
xcode_runbuild / test / clean / archive / run. Streams the full log into a background run and returns the collected compiler errors. run installs with simctl on a simulator, devicectl on a device CoreDevice knows, ideviceinstaller + ios-deploy on iOS 16 and earlier, and does neither for macOS.
xcode_logThe run's log: buffered tail, incremental slice by line number, or a regex-filtered view. Safe to call mid-build.
xcode_lldbDebug the app this workspace last ran. view-hierarchy attaches LLDB to it, stops it, returns the key window's view tree (class, frame, text, hidden, stack-view attributes) and writes the same tree, with a screenshot cropped per view, as a .lookin file Lookin.app opens; lookin opens that file; command runs any raw LLDB command (po, bt, breakpoint set, expression) in the same session; attach / interrupt / detach / status manage that session. On an iOS 16 or earlier device, which CoreDevice cannot reach, LLDB attaches through a debugserver port forwarded by pymobiledevice3 (pipx install pymobiledevice3); the canvas draws each control's colours, text and pictures, fetched in the background and cached per process.
xcode_device_logThe log of a simulator (simctl spawn, snapshot or a bounded live window) or of physical hardware. On iOS 16 and earlier, pass bundleId and the app's own per-launch log (Documents/PPCrashLog/log_<timestamp>.log) is read out of its sandbox — the file that survives a crash, readable while the device is locked; mode: "syslog" gives the live idevicesyslog window instead.

Model guidance, so the tools are used without being named

  • A short system-prompt section (dsh-xcodebuild, via ctx.systemPrompt.section) is present on every step. It tells the model to use the xcode_* tools instead of shelling out to xcodebuild, and which tool answers which situation (build errors, a crash after launch, a missing tool).
  • A runtime skill, xcode-build-loop (via ctx.skills.register), holds the full procedure: pick project → scheme → destination, run, read errors, fix, rerun, and read the device log. The model loads it on demand, so the long text costs nothing until an Xcode task comes up.

Both are optional services (ctx.inject), so a profile without them still mounts the tools.

The panel follows the model, and comes forward for a build. xcode_run tags its run with the calling session's working directory, so the panel open on that project picks it up within a few seconds of idle polling: it clears the old log, streams the new one, matches scheme / configuration / destination to what is actually building, and marks the run by agent. Stop works on it like on any other run.

A build, run, test or archive started by the model also opens the panel: the official right sidebar is asked for this plugin's tab with sidebarRight.openTab, and a shell whose column is owned by better-sidebar — or that has no sidebar at all — gets the floating panel instead, because a step nobody can see is the one thing this is for. Reads (xcode_lldb, xcode_device_log, xcode_log, and the rest) never take the screen that way; they are recorded in the panel's AI strip instead, which names the step in the panel's own words (正在构建 · Enshi · Debug) with how long ago it was. The switch beside it, AI 调用时自动打开, turns the opening off and is remembered in localStorage under dsh-xcodebuild:autoOpen (on by default). The host records each step in agentSteps (noteAgentStep), keyed by workspace like the runs, and the panel is shown only its own session's.

A panel, in three seats, taking whichever sidebar the shell has:

  • Docked in dsh-better-sidebar, when that plugin is installed — via ctx.betterSidebar.registerTab, so it appears as a tab in its workbench (right sidebar or bottom panel) with a title chip and a close button supplied by the host. The tab is added from better-sidebar's own list; there is deliberately no permanent header button, because a second fixed entry beside the session title would only be clutter once a sidebar can host it.
  • Docked in the shell's own right sidebar, when better-sidebar is not installed — via ctx.sidebarRightTabs.register, the same tab registry the shell's Files tab uses. The panel becomes a tab type there, listed in that sidebar's Guide (a capsule carrying the title and description) and in its add-tab menu; the shell draws the chip and the close button. Same rule: no header button, because the sidebar offers the tab itself.
  • Floating — the fallback for a shell with neither sidebar: the same panel in shell.overlay, with its own head row and close button, and a header button to open it — that button is the only way in when there is no sidebar to add the tab from, so it survives for exactly that case and renders nothing otherwise.

One mark wherever it is docked. The plugin is recognised by the same four-blade drawing in every seat: the header button's emblem, the icon better-sidebar draws in its tab strip, the shell's Guide capsule, the shell's tab chip, and the floating panel's head row. The seats disagree about how a size is handed over — better-sidebar calls icon(size), the shell's Guide renders entry.icon as a component and passes {size, className} — so one component (Mark) is the drawing and a one-line adapter answers the first shape. Each drawing declares its own gradient, because several seats are on screen at once and url(#…) would otherwise resolve to whichever identical definition the document happens to hold first. test/client-interaction.test.mjs compares the drawn path across the seats, so "the same mark" is a test result rather than a claim.

The two sidebars are alternatives rather than layers: better-sidebar wins when it is there, and the official right sidebar is the fallback. Because each service arrives whenever it arrives, syncSeats() reads both and gives back a seat it has to, so the arrival order cannot leave the panel in two places or in none.

Both are reached through ctx.inject([…], …) and deliberately not through dsh.client.inject. That list is a hard dependency: naming dsh-better-sidebar (or the right sidebar's package) there would stop this plugin's client half from loading for anyone who does not have it, which is the opposite of having a fallback. All three seats are covered by test/client-interaction.test.mjs.

Whichever seat it lands in, the panel gives you: a project picker (below), scheme / destination / configuration selectors with a ⟳ beside the destination list that re-reads it on demand, Build / Run / Test / Clean / Archive / Stop, a colour-coded streaming log, and a filter bar.

A debugger, in a drawer along the bottom. ⌘L, or the LLDB button in the status row, opens a strip that is hidden the rest of the time. View Hierarchy answers the question a screenshot cannot: it attaches LLDB to the app this project last ran, stops it, and draws the key window's view tree — class, address, frame, text, hidden flag, and a stack view's axis/distribution/alignment, which is usually why a screen looks wrong. The stop is the old way of doing it: the app loads a small helper the first time it is read (see Reading an app without stopping it), and from then on it answers for itself while it keeps running. A filter narrows it by class, text or address without asking the host again — the rows that match are highlighted, the ancestors that place them are dimmed, and the bar says how many matched. The tree folds like Lookin's: a triangle on every row that has children (⌥-click for a whole branch), the arrow keys to walk and fold it, the fold kept by address so a re-read leaves it as it was, and three modes — smart content-open default, everything open, or only the outer windows — with a view picked on the canvas opening its own way into view. And a box at the bottom takes any LLDB command. 添到聊天 — on a view's right-click menu, on the picked view's chip, on any line of the build log or the LLDB transcript, and beside a build's error count — puts it into the chat as a reference that says where it came from (source), what exactly it points at (refers to), and which tool call reaches it or reads around it, so the model knows precisely what is meant. The drawer is the same session the model uses, so a session the model starts opens it — transcript and all — and the user can take the prompt over from there. ⤢ gives the drawer the whole panel (and ⤡ gives it back; the panel itself is resizable by its corner), and a double-click on a row focuses that view's subtree.

The canvas shows the app, with the view frames on it. Between the tree and the inspector sits Lookin's preview, built the same way — from LLDB alone. It is the window's own picture, rendered inside the app while it is stopped (one view, scale 1, one short stop) and drawn in the canvas's own coordinates, so it zooms, turns and pans with the boxes; 2D shows the frames flat, 3D pulls them apart by overlap depth and a drag turns the stack, 隐藏视图 draws the hidden ones dashed, and a box carries its class name when it is big enough on screen to hold one. A slide moves the camera and never the scale — a trackpad reports a two-finger slide as a wheel event with no modifier, which is why every scroll used to change the zoom — while a pinch (or ⌘+wheel) zooms and the percentage beside 适配 is itself a control: drag it sideways, double-click it for 100 %. Clicking a box picks that view, a double-click focuses it, a right-click opens the row's menu — and 截图 turns the picture off for a pure frame drawing, 刷新截图 reads it again. Rendering the app's own layer is also what fixes what used to be invisible here: no CoreGraphics struct may be a value in an LLDB expression on Xcode 26 (error: attempt to use a deleted function for CGSize size = [layer frame].size and for CGRectMake, in every language mode), so the geometry travels as four doubles out of NSValue and the context is a bitmap context — which is what made the per-view images real rather than always falling back to a cropped screenshot. Where even that cannot run — a device whose UIKit module the installed Xcode has no SDK for, so no CGFloat and no CGContextRef can be named — the window is a screen capture cropped to its frame instead, taken with the platform's own tool and sips, which needs no debugger at all; the note beside the picture says so.

An inspector beside the tree, built on LLDB alone. Picking a view opens three panes and no Lookin server anywhere. They are drawn from one read: the stop that reads the tree also reads every view's attributes and layout, and a click afterwards costs nothing — no second attach, no second stop, no round trip at all. Attaching is the expensive half (4 s to a simulator, 15–23 s to a phone), while one _ivarDescription on a process that is already stopped costs about 20 ms and one layout report about 10 ms, so reading them per click paid the expensive half over and over. 96 views of a test app come back as 2.9 MB of detail in 6 s, once. The pass is bounded (400 views, 6 s) and says how many it reached — 已缓存 N 个视图 in the tree's stats, with (部分) when it stopped early — so a view the bounds left out is still read on demand when it is opened, and 刷新 re-reads the picked view even when it is cached:

  • 属性 — everything the app says about the object, grouped by the class that declares it, read with _ivarDescription. ⌘L-style rows that can be written get an editor chosen from the declared type: a switch for a BOOL, a field for a number, a text field for a string, a colour well for a UIColor, four boxes for a CGRect. The edit goes to the app as setValue:forKey: and the pane then shows what the app reports, so a value its own layout code writes back says so instead of pretending to have changed.
  • 布局 — Auto Layout in one expression: whether the autoresizing mask is still translating, whether the engine calls the layout ambiguous, the intrinsic size, hugging and resistance, this view's own constraints, and the ones living on its ancestors that mention it. An address inside a constraint's text is a button that selects that view.
  • 预览 — the rendered image of the view, solo or with its subtree, with zoom and pan.

Above the panes, the chain from the window down to the picked view, each link a jump.

A tree Lookin can open. Lookin is the better window onto a hierarchy, and the Lookin button in the drawer opens the tree there — but Lookin normally needs its own server inside the app. Lookin being installed is checked rather than assumed: with it the drawer offers Lookin, and without it the same slot offers Reveal — the file in Finder — because a button labelled "Lookin" that quietly opened Finder would be a lie. This does not: every read writes what it just read as /tmp/dsh-xcodebuild/lookin-<when>.lookin, which Lookin.app opens directly. That is what makes an app reachable only by the debugger — no LookinServer, a release binary, a device you can attach to and nothing more — browsable in the same tree view, with each node's class chain, frame, alpha and hidden flag — the frames are the same superview-relative numbers the panel prints, which is the space Lookin's own files use. The class chain is asked of the runtime, one expression per distinct class, which is why the first read of a session takes a moment longer than the ones after it. Ten files are kept.

A row is a question. Clicking a view in the tree opens a detail pane beside it: the view's own image — Solo for the control alone, Group for it with its subtree — then the numbers behind it, its frame and bounds, its background colour as a swatch, its layer class and the chain of classes it inherits from. That is Lookin's window onto a hierarchy, in the drawer, without Lookin installed.

The pane is fetched per click rather than shipped with the tree, because the tree is text and the images are not: one view's two renders at a time, parsed by the host, so a colour that comes out the wrong shade is a failing test instead of a screenshot someone has to squint at.

The images are the views' own. Each node carries the two images Lookin shows, and both are rendered inside the app, because a screenshot cannot produce either: solo is the control alone (its sublayers are hidden while its layer is drawn, the way LookinServer does it) and group is the control with its subtree. Cropping one screen capture, which is what this used to do, gives a region of a flat image — never the control alone, and for anything scrolled off screen not even a faithful view of it.

The renders come out through the app's own sandbox: a simulator's data container is a directory on this machine, so the PNGs are read straight out of it, while a physical device is fetched with xcrun devicectl device copy from. Views are rendered at up to 1024 pixels on their long edge and views too large for a drawing context are skipped. When the render cannot happen at all — an iOS 16 or earlier device has no devicectl — the export falls back to cropping one screen capture, and the note beside the file says so.

Measured on an iPhone 13 (iOS 26.6.2) under Xcode 26.0.1, because these are the facts the implementation is shaped around:

  • A device attach is asynchronous and slow. device process attach -p <pid> returns at once, and the process stops 6-25 s later with the stop arriving as a message. The plugin waits for Process N stopped and nudges with process status; it does not treat a thread's stop reason = … line as the process being stopped — LLDB prints one while it still reports the process as running, and trusting it made every expression fail.
  • In that window, nothing but script may be asked. process status and process interrupt both need a process LLDB has already launched, so while the attach is settling they answer error: Process must be launched. and nothing more; asking for a stop once every 3 s printed seven of them on one attach, for a nudge that changed nothing, since the attach itself is what sends the app SIGSTOP. The wait therefore reads the process state first — script print("xcb-lldb-state=" + str(lldb.debugger.GetSelectedTarget().GetProcess().GetState())), which is answered whatever the state is, including 0 with no process at all — and interrupts only a process LLDB reports as running: an app that was already running when the attach happened is the one case where attaching does not stop it.
  • A process must have run before it can be inspected. Launching under the debugger (mode=launch) starts the app for real and attaches after it has had time to build its UI. With devicectl --start-stopped instead, the dump returned error: use of undeclared identifier 'UIApplication' — no windows, no Objective-C runtime.
  • The dump is one expression: po [[[[UIApplication sharedApplication] windows] firstObject] recursiveDescription]. windows is deprecated but still answers on iOS 26, and its first element is the key window. Each view line's depth is the number of | before its <, so the tree is rebuilt from indentation rather than trusted from a pretty-printer.

Choosing a project

Naming a directory is not enough to name a project. A real iOS checkout offers several, and almost all of the extra candidates are noise:

Gemoy.xcworkspace                     ← the one you want
Gemoy.xcodeproj                       ← the target list that workspace already wraps
Gemoy.xcodeproj/project.xcworkspace   ← inside the bundle above
PodCache/MLeaksFinder/*.xcodeproj     ← a cached pod
OtherProject/YNLive/YNLive.xcworkspace ← a genuinely different project

So the path field searches rather than asserts, and the search result decides:

  • one hit — adopted outright, no question asked;
  • several — a picker listing each by name and relative path, so the choice is informed rather than a guess made for you;
  • none — said plainly, as an empty answer rather than an error.

A chosen project keeps a Change button that brings the picker back. Two rules remove the noise: a search never descends into a .xcodeproj / .xcworkspace (they are bundles, not containers of other projects), and a .xcodeproj sharing a basename with a .xcworkspace beside it is dropped as the workspace's own target list. Pods, PodCache, Carthage, build, DerivedData, node_modules and dot-directories are skipped, the walk is depth-limited, and it is capped so a huge tree cannot stall the panel.

Filtering, and searching

Filtering and searching are different questions, and the panel keeps them apart. A filter changes which lines exist as far as the panel is concerned — they disappear, and it can reach lines the browser no longer holds by asking the host. A search changes nothing: it marks the hits inside the lines already on screen and takes you to them.

Filtering — which lines

Two axes, combined:

  • Text — committed on blur, not on every keystroke. Typing a filter is one intent and running it is another; a per-keystroke filter would re-query the host and repaint the list on every character, churning the log under the caret while the pattern is still half-written. Press Enter or click anywhere else to apply (the field outlines in amber while uncommitted). Esc clears. ↑/↓ walk the filters used before, without committing one.
  • Severity — the four levels a system log has, in its own words: verbose, info, warning, error (see the table below for how the app's LogLevel is written to the device). A build log has more kinds of line than that, so the build-only ones join the level they belong to: the compiler's notes are part of a diagnostic and sit with the warnings, the progress chatter (CompileSwift, Ld, …) is the lowest level there is, and sections, test results and "BUILD SUCCEEDED" are information. How a line is drawn does not change — a note is still grey, a finished build is still green. The toggles apply immediately, joined by a Problems shortcut for "the diagnostics only" (which keeps notes with their warnings). The levels are drawn from one table in lib/client.js, and the suite walks the buttons and proves that between them they reach every kind the classifier can produce — a kind with no button would be a line nothing could ever show again once it was off.

Text filtering runs on the host, not in the browser. The panel keeps a rendering window of the log; the host retains the last 20 000 lines per run. A filter that only searched the browser window would silently miss the beginning of a large build — exactly the case where you reach for a filter. The .* button switches the pattern to a regular expression; an invalid one falls back to the local substring match instead of erroring on every poll.

Searching — where in those lines

⌘F (or Ctrl+F) opens the search bar at the top of the log. It is hidden the rest of the time: a whole row of the panel is a lot to spend on a job that usually takes a second, and the binding is the one a browser's own find has already taught everyone. While it is open the caret is in the box — a second ⌘F selects what is there, so typing replaces it. An empty bar closes itself when focus leaves it — clicking away, or Esc — because an empty search box is a row of the panel with nothing in it. The judgement is made on blur and never on the keystroke: deleting a character to retype it is not the user saying they are done, and a bar that vanished mid-edit would be a bar you could not type in. Focus moving to the same box in another seat of the panel (docked and floating can both be mounted over one store) is not "leaving" either. The bar searches what is on screen, live, and hides nothing:

  • The count is live — 3 / 17 — because "is this string in this build at all" is the question you are asking while typing, and making you press Enter to find it out would be the filter's behaviour applied to the wrong question.
  • Every occurrence is highlighted, and the line you are currently on is highlighted differently, so the number in the count and the mark on screen always agree.
  • ↑ / ↓ (and Enter / Shift+Enter) walk the hits and wrap around the ends; they are greyed out when nothing matches, and the count says no hits rather than 0 / 0.
  • Mov