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
Five model-facing tools
| Tool | Purpose |
|---|
xcode_doctor | What 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_project | Detect a .xcworkspace / .xcodeproj / Package.swift; list schemes, configurations, targets, and any SweetPad defaults in .vscode/settings.json. |
xcode_destinations | xcodebuild -showdestinations — simulators, USB devices, My Mac — each with a ready-to-use destination string, plus iOS 16 hardware that -showdestinations omits. |
xcode_run | build / 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_log | The run's log: buffered tail, incremental slice by line number, or a regex-filtered view. Safe to call mid-build. |
xcode_device_log | The 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. |
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.
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.
- Moving to a hit scrolls it to the middle of the view and stops the view following the tail —
the
↓ Latest button appears, which is how you get back to watching the build. Typing alone never
moves the log; only asking to go somewhere does.
- The needle is literal text, case-insensitive, never a pattern: a build log is full of
[, (
and *, and ( has to find a bracket rather than be compiled. Esc clears.
- Hits past the render window are still reachable: the window (last 2 500 lines by default)
follows the current hit, so pressing
↓ cannot walk into lines that are no longer in the DOM.
↑/↓ walk what each box was given before
Both boxes keep their own history — the filter's and the search's are separate, because a filter and
a search are different questions — and ↑/↓ walk it the way a shell does: ↑ is back in time and
↓ is forward, at the oldest entry it stays rather than wrapping, and ↓ past the newest puts back
the half-written text that was in the box when the walk started. Only finished inputs are
remembered: a filter when it is committed (Enter or clicking away), a search when it is finished
with (Enter, or Esc). So ↑ never offers a prefix of what is being typed, and typing again ends
the walk — which is what makes ↓ mean "my draft" again.
What it needs on the machine
Run the xcode_doctor tool — or open the panel, which says the same thing in a warning row — to see
which of these are present, with the version of Xcode in use. Missing entries are reported with the
command that installs them; a tool that is absent is never left to surface later as
spawn … ENOENT or as a device-not-found that blames the phone. The classic channel's three formulae
are the only optional gap the doctor offers an install command for: a missing xcbeautify changes how
the log reads and nothing else, so it is reported without being turned into advice to install the
device toolchain.
| Tool | Provided by | Needed for |
|---|
xcodebuild, xcrun, xcode-select, plutil | Xcode and macOS | everything |
idevice_id, ideviceinfo, idevicesyslog | brew install libimobiledevice | seeing, identifying, and reading the log of an iOS 16 or earlier device |
ideviceinstaller | brew install ideviceinstaller | installing onto such a device |
ios-deploy | brew install ios-deploy | launching on such a device |
xcbeautify | brew install xcbeautify | turning build output into one line per task, with warnings and errors marked. Optional, and used automatically when it is installed |
These are three separate formulae, and that is the trap. brew install libimobiledevice
provides neither ideviceinstaller nor ios-deploy, so following the obvious instruction leaves
both install and launch broken:
brew install libimobiledevice ideviceinstaller ios-deploy
The device tools are optional in a strict sense — simulators, macOS, and every device Xcode manages
through CoreDevice (iOS 17 and later) need none of them. They are not optional if you plug in an
iPhone X, because devicectl cannot see it at all: it is not merely refused there, it is absent from
xcrun devicectl list devices, which is why such a device used to fail as one that does not exist.
test/dependencies.test.mjs scrapes every command lib/index.js can run out of its own source and
fails if one is not registered, so this table cannot silently fall behind the code.
Installation
dsh plugin --profile web add /path/to/this/directory
Then restart the profile.
A link: install needs the host package linked too — or DSH lands in Safe Mode
lib/index.js imports defineTool from @deepseek-ai/dsh-tools as a runtime value. Host
packages are deliberately not dependencies: a plugin installed normally resolves them by walking up
the directory tree into profiles/node_modules, where the deployment puts them.
A development install from a directory is recorded as link:, which puts the plugin's real path
somewhere outside the profile tree — so that walk never reaches profiles/node_modules, the import
fails, the plugin tree fails to load, and DSH treats a failed plugin load as fatal: it recovers into
Safe Mode with every third-party plugin disabled. The tell is in
~/Library/Logs/DSH Desktop/harness.log:
plugin recovery detection: dsh-xcodebuild
[desktop] safe mode: third-party web profile bundles are blocked
The loader's own message is misleading — it reports the outermost specifier, because its retry path
swallows the real error:
Cannot find package 'dsh-xcodebuild' ← not the actual problem
Cannot find package '@deepseek-ai/dsh-tools' imported from .../lib/index.js ← the actual problem
So after any rm -rf node_modules, run:
npm run link-host
node_modules/ is gitignored, so a fresh clone needs this once. To avoid the fragile link entirely,
install the plugin as a real package instead — pack it and add the tarball, so it lands under the
profile where host packages resolve on their own:
npm pack
dsh plugin --profile web add file:/absolute/path/to/dsh-xcodebuild-0.1.1.tgz
Notable behaviour
-
Derived data is Xcode's own, so builds stay warm for Xcode.app as well. Pass
derivedDataPath to redirect it.
-
The built .app is located via -showBuildSettings, not by constructing a path: with the
default derived data the product directory contains a per-project hash.
-
Log lines are classified in-process (see lib/classify.js): the panel colours errors, warnings
and tasks, and the severity filter groups them, so the kind has to be attached to each line as it
arrives.
-
xcbeautify is used automatically when the machine has it, and is invisible when it does not.
Nothing is configured: xcodebuild's output is piped through it, and the panel prints the pipeline
it is running. Three details make that safe rather than clever:
- Only the flags that version lists in
--help are passed, and the resulting argv is then run
once on a trivial line before any build relies on it. A formatter that rejects its argv exits at
once and leaves the build writing into a pipe nobody drains; whatever that does to the build, it
is not something a log formatter is allowed to cause. When the probe fails, the formatter is
simply not used.
NSUnbufferedIO=YES is set on the formatter. Measured on xcbeautify 2.28.0: with stdout on a pipe
rather than a terminal its own output is block buffered, so the panel would receive the whole
build log at the moment the build ended — no progress, and no errors while they still mattered.
With it, lines arrive as the build produces them (measured at 0.8s / 1.5s / 2.2s against a producer
printing every 0.7s, versus one 6000-byte burst at the end).
--preserve-unbeautified is included, because xcbeautify silently drops the task lines it does
not recognize (CompileSwift, Ld) without it, and --disable-colored-output keeps escape
sequences out of a panel that does its own colouring.
Set DSH_XCODEBUILD_NO_BEAUTIFY=1 to leave it off on a machine that has it. If the formatter dies
mid-build the run keeps going with raw output and says so on its own line.
-
Archives land in ~/Library/Developer/Xcode/Archives/ so Xcode's Organizer lists them.
-
A workspace is asked twice for its configurations. xcodebuild -list -json -workspace X.xcworkspace
answers with { workspace: { name, schemes } } and reports no configurations at all — the
configurations belong to the inside. Asking only the workspace therefore makes every
project look like it has nothing but and ; a project that also builds
silently loses it from the picker. follows up with the wrapped (named
after the workspace, or the only one in the directory) and merges the two answers. The merge is a
pure function in , pinned against real captured output by .
Layout
lib/ring.js fixed-capacity per-run log buffer (O(1) push; see the test for why)
lib/classify.js one kind per log line — drives colour, filtering and the error summary
lib/beautify.js `xcbeautify`'s flags, the escape sequences to strip, and the pipeline line shown
lib/projects.js which Xcode project a directory holds, and which ones it offers
lib/parse-destinations.js `-showdestinations` parsing, destination kind, and the order the list is shown in
lib/listing.js folding `-list -json` answers (a workspace needs two) into one listing
lib/legacy-launch.js whether an iOS 16 launch actually happened — pure, so the `success` trap stays pinned
lib/legacy-applog.js which `log_<timestamp>.log` is the newest launch, and where the AFC download lands it
lib/lock-state.js whether the device is asking for its passcode, per channel
lib/syslog.js the live feed: the device log relay's argv, its escaping, and its severity
lib/modern-launch.js the CoreDevice launch: the console-attached argv, and how a launch is witnessed
lib/session-files.js which files a previous launch left behind, and when they may be deleted
lib/log-tap.js the separate reader that pulls a live launch's console into the panel
lib/index.js host: run registry, the six tools, the panel's JSON routes
lib/client.js browser: header toggle, dock tab, overlay fallback
test/ plain-node suites (no test framework) run by `npm test`
Tests
npm test # ring buffer, classifier, destinations, project search, listing, client, device channel
npm run test:mount # mounts the host half against a fake context (needs one link, see below)
npm run test:live # real build against a real project (slow; needs Xcode)
test/ring-buffer.test.mjs imports the shipped lib/ring.js (not a copy) and cross-checks it
against a reference implementation over 300 randomised trials, then measures the eviction path the
buffer exists to replace.
test/classify.test.mjs pins the classification rules against real xcodebuild output — including
the file:line:col: note: form that a line-start anchor never matches.
test/beautify.test.mjs runs the real xcbeautify where it is installed: that it accepts exactly
the flags passed to it, that its output still classifies as errors, warnings and tasks, that
--preserve-unbeautified keeps the task lines it would otherwise drop, that a line is delivered
while the build is still running rather than in one burst at the end, and that a formatter which
dies mid-build leaves the run reporting the build's own exit code.
test/destinations.test.mjs parses captured -showdestinations output for a 24-scheme
CocoaPods workspace. Two bugs are pinned here: a value can contain a comma
(variant:Designed for [iPad,iPhone]), so fields must be split on commas that begin another
key: rather than on every comma; and platform:macOS is printed FIRST, so a "first entry wins"
default silently targets My Mac for an iOS app.
test/find-projects.test.mjs builds a synthetic checkout holding exactly the noise a real one
does — a wrapped .xcodeproj, a workspace inside a bundle, Pods, PodCache, a dot-directory, a
project past the depth limit — and pins which of them a search may offer.
test/client-interaction.test.mjs mounts the browser half in jsdom and drives it, in all three
seats: as a better-sidebar tab (asserting the contributed descriptor and that the header button opens
the tab and mirrors it), as a tab of the shell's own right sidebar (asserting the registered type —
id, kind, band, title, Guide capsule — that the body renders the docked panel, that the tab names its
session, and that the header button goes away), and as the overlay fallback (open, close, reopen, and
commit a filter on blur). The two sidebars are also mounted together in both arrival orders, proving
the fallback seat is given back when the dock turns out to be in charge.
It also walks the picker: two candidates are offered, clicking one adopts it, and Change brings the
choice back. The device list is driven the same way, with a transport that answers late on purpose:
the cached list and its age are on screen while -showdestinations is still running, the command is
issued anyway, and the live list then replaces the cache — including a selection that no longer exists. This one earns its keep twice over — it caught the store draining its subscriber list
on the first emit, which left every later click deaf.
The client test borrows react and jsdom from the deployment, so it expects the app at the
default /Applications/DSH Desktop.app path. The host half imports @deepseek-ai/dsh-tools, which
the running harness aliases for a mounted row rather than installing into the plugin. test:mount
therefore needs it resolvable — link the deployment's own copy, not a second install, or the module
instance under test is not the host's:
mkdir -p node_modules/@deepseek-ai
ln -sfn "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh-tools" \
node_modules/@deepseek-ai/dsh-tools
node_modules/ is gitignored, and the runtime never needs it.
Security
The panel's routes are served by the profile's webServer, and every request is put through the
composition's connection.requestRejection first — the Host/Origin fence that defeats DNS
rebinding and cross-site calls, plus the browser auth cookie. No bespoke trust logic lives here, so
it cannot drift away from the rest of the GUI.