The Software module¶
What is installed on the machine, and the ability to start it — the inventory sibling of the Processes page's "what is running". This spec is the module's contract before any of its pixels exist.
The doctrine, stated once: console first. Every capability lands as
a console verb over a tested data layer, gets watched working (emulator,
then metal), and only then grows UI — the UI renders functions that
already work, it never introduces behaviour. This is the lesson of
two-halves-never-met-in-a-test applied one layer down: the page and
its logic must not meet for the first time on the PowerBook. Rung 0
below is already done and verified; it is the pattern the rest follows.
The ladder¶
| Rung | What | Status |
|---|---|---|
| 0 | catsearch probe; software.c; sw + launch verbs |
Done. catsearch metal-verified (3.8 s cold, 22k files); sw/launch emulator-verified, metal pending |
| 1 | Console completion: resumable sweep, vers, running tags |
Next |
| 2 | Wire family: software.list / software.listing, paths |
After 1 |
| 3 | Guest page (the six edits + Data Browser) | UI on top |
| 4 | Host module page | UI on top |
Each rung ends the way every arc here ends: suites green, a watched run,
and an open-issues.md entry saying what is still unwatched.
Ground truth from rung 0¶
Decisions below lean on measured facts, not hopes:
- A whole-volume APPL sweep costs 3.8 s cold on the 1400c (22,127 files), warm barely cheaper — so a live sweep is affordable, a cache file is not needed, and nothing should treat re-sweeps as free.
PBCatSearchreturns roughly once per 16 KB opt buffer, worst slice 3 ticks — so the buffer size is the slice-length dial andidle()-driven slicing works as designed.- The special folders are dozens of files — enumerating them live, every time, is cheap enough to never cache.
- 601 applications on the real disk — so the expensive read is not
the sweep but per-item
'vers'resource opens, which stay lazy, one visible/selected item at a time.
Rung 1 — console completion¶
Three pieces of logic the page needs, each proven as (or under) a console verb first.
The resumable sweep¶
software.c's appl_sweep is a blocking loop today. The page needs the
same sweep one slice at a time from idle(). Refactor to a stateful
iterator — the CatPositionRec already is the resumable state:
typedef struct {
CatPositionRec pos;
long spent_ticks;
Boolean done; /* eofErr reached, or stopped by rule */
OSErr err; /* why, when done and not eofErr */
} SweepState;
void now_software_sweep_begin(SweepState *s);
/* One PBCatSearch slice; calls collect per hit. Returns the number
collected this slice. */
int now_software_sweep_step(SweepState *s, ConstStr255Param name_or_null,
SweepCollect collect, void *ctx);
sw apps and launch's name search become loops over step — the
console verb is how the iterator gets exercised and watched before
idle() ever calls it. Same rules as today: catChangedErr ends the
sweep (a restart double-counts), the opt buffer is allocated at begin
and freed when done.
vers — the lazy detail read¶
The one-item version read the page's detail pane will render:
vers <name or full path>
Resolves exactly like launch (bare name = exact-name sweep, refuses
ambiguity), then opens that one file's resource fork read-only
(FSpOpenResFile … fsRdPerm), reads 'vers' id 1 (and id 2, the
region/product string) and the short version string, closes the fork,
and prints them. Rules:
- Resource-fork opens are the measured expensive path: this verb never loops over an inventory. One call, one file.
- A file with no
'vers'answers "no version resource" — absence is an answer, the census rule. ResErrorafter every call; a damaged fork must not crash the read. Close what was opened on every path, and restoreCurResFile.
Layer: now_software_vers(const char *arg, rows…) in software.c, so
the page calls the same function per selected row.
A bare name that matches several applications shows every match, numbered, full path wrapped (bounded at five) — the metal run found multiple SimpleTexts immediately, and which copy answered is the whole point on a disk with duplicates.
Launch disambiguation (settled after two metal rounds, 2026-07-22). The path here was instructive: it went refuse-and-list → launch-newest → the shape below.
launch <name>— name is the whole remainder (spaces need no quotes; surrounding quotes are stripped if used, so the Unix reflex is harmless). One match launches. Several launches the first found and the reply names its version — a visible answer, cheap (one fork open to name what we opened, no vers walk).launch -v <version> <name>— a leading flag forces the copy at that short version string (reads candidates''vers'to match — bounded, explicit). The flag is leading-only so the name after it stays whole.- Full path and
#n(from the last search's stored matches, whichvers <name>prints) round out the explicit picks.
Why not require quotes, Unix-style? Because classic-Mac names are
space-heavy ("Adobe Photoshop® 5.0"), so the whole-remainder rule is
far less friction than quoting every launch; the -v flag removes the
positional-version ambiguity that made a tokenizer tempting in the
first place. Why first-found not highest-version by default? Reading
every candidate's 'vers' on every ambiguous launch is the expensive
path we measured; first-found + a named version + -v to override is
the honest, cheap middle. Marked for later, not this ladder: a
duplicate finder — group the sweep by name, lazily vers each
group, present same/different-version pairs for consolidation.
Running tags — the Processes join¶
sw apps (and the wire listing) tag an item that is currently running.
The join is mechanical: one Process Manager walk per gather
(GetNextProcess / GetProcessInformation), compare the process's
FSSpec (vRefNum, parID, name — not the display name) against each
row's. Cost: the walk is what ps already does; the compare is memcmp.
Console proof: (running) appears in the detail column, and quitting
the app makes a re-run drop it.
Rung 2 — the wire family¶
The host page cannot page through a 4 KB command.result, so the
inventory becomes a first-class message pair, shaped like
process.list:
software.list { type, id, domain, cursor } cursor 1-based
software.listing { type, id, domain, entries[], cursor, more }
Entry fields — all present unless marked optional:
| field | ||
|---|---|---|
name |
string | catalog/display name |
path |
string | full path, the launch key (below) |
type, creator |
string | 4CCs, MacRoman-escaped like all wire text |
sizeK |
int | data + resource forks |
off |
bool | disabled-folder sibling |
running |
bool | the rung-1 join |
version |
string, optional | the 'vers' short string, read per served entry (a page's worth of fork opens per request — bounded, explicitly asked-for); absent when a file has no 'vers' |
Paths are the launch key. The host launches a listed item by sending
the existing launch command with entry.path — one mutation verb in
the whole family, and by full path, so the ambiguity refusal never fires
from the UI. Building the path: a folder domain knows its own path once
per listing; an apps-sweep hit walks parID upward with a small
per-listing cache. Guest-side logic, exercised by the listing itself.
Symmetry, declared honestly. software.list means the same thing
whichever side sends it — but NOW is a cockpit, and the family follows
the process.list precedent exactly: the host asks, the guest serves,
and the host ignores a software.list rather than serving one. That
asymmetry is stated in the contract's operation description, not
drifted into. (An earlier draft had the host serving /Applications;
the established sender-only rule for the host's receiving half won.)
Conformance. software.listing is built across snprintf calls, so
GuestWireConformanceTests will fail until it gets a hand-written
fixture in GuestWireFixtureTests — that failure text is the reminder,
and includes a ®-bearing name to pin the MacRoman escape path. The
testMessagesThisCannotCheckAreKnown set gains software.listing.
Logging: software.list logs once per refresh (cursor 1) under sw,
the process.list rule.
Rung 3 — the guest page¶
Only after rungs 1–2 are watched working. The page renders functions
that already ran; its jobs are geometry, controls, and pacing.
An interactive Platinum mock of every state — populated, sweeping,
duplicates, extensions — is
mockups/software-mockup.html, in the
same split-view idiom as Processes/Hardware and at the real geometry.
It carries the open button-model question for non-app domains. (The
whole control set, Data Browser included, is native-api-route on
CarbonLib 1.6.)
Placement. Module id after Hardware, before the footer pair: sidebar order Screenshots, Files, Console, Processes, Hardware, Software, then Logs / Connection pinned. View menu gains Cmd-6; Logs and Connection shift down (check whether the persisted selected page in prefs stores the module id — if it does, that is a prefs format bump, the Connection-renumber precedent).
Layout. Domain popup at the top (Applications, Extensions, Control
Panels, Startup Items, Apple Menu — popup, not tabs: five domains do
not earn tab chrome). Below it a Data Browser list: Name (icon-less
text first; icons are a later nicety), Kind (type/creator), Size, and
a state column (off / running). Below the list, the detail line and
buttons. All geometry in compute_rects over the body rect, pure, with
a host-cc test (workshop_layout_test.c pattern).
Filling, by domain price. Folder domains: enumerate on every page
show — live and instant, no staleness to manage. Applications: first
visit starts the rung-1 sweep, one step per idle() pass, rows
appended as slices land, placard reading "Sweeping Macintosh HD — 214
applications…" (the status_text op). Sweep state is kept for the run
(modules are never disposed); a Rescan button re-runs it. No cache
file — 3.8 s of honest progress beats stale-and-wrong.
Idle discipline. One slice per pass, nothing else; every drawn
value behind a g_shown_* cache; no HiliteControl unconditionally.
The sweep must also yield entirely while a transfer is in flight
(conn knows) — the no-sleep loop is the one place a 3-tick slice
would still be felt.
Actions. Launch (selected, by FSSpec — the page has it; never by
name), and for a running row Front / Ask to Quit, reusing
proc_actions exactly as the Processes page does. Launch failures go
to the placard and the log (already the rung-0 rule). Selecting a row
triggers the one lazy vers read for the detail line; cache the answer
on the row.
Show in Finder is the one universal action — a Finder reveal Apple
Event (kAEFinderEvents) on the FSSpec the row already holds. It is
enabled for every selection in every domain, which is what makes a
non-app row (an extension, a control panel) worth selecting at all: the
launch/front/quit set does not apply to them, but show me where this
lives always does. This retires the earlier "dead button row" question
for non-app domains.
Search filters the cache, never the disk. A plain Appearance edit field (there is no Aqua search control on Platinum) refilters the displayed rows on every key-down — substring over the already-gathered names, microseconds, so a live per-keystroke refresh is affordable. The rule that makes it safe: it re-filters the in-memory list; it never re-runs the sweep. A search that touched the disk per keystroke is the one thing this must not be.
Version in the list, by trickle. Version lives only in 'vers', and
the catalog walk does not carry it — so there is no free source, only a
resource-fork open per file. The sweep therefore stays catalog-only and
fast, and a second idle-paced pass opens each fork and fills the
Version column afterwards, caching the answer on the row (the same read
vers already does, just eager for the list instead of lazy for the
detail). Rows show blank until their version arrives; nothing blocks on
it, and a re-sweep is never triggered to get it. The console sw verb
can gain the column the same way, folded into its run.
The six edits, instantiated. workshop_module.h (id 6 +
kWorkshopModuleCount), workshop_layout (nav_rows 5 → 6),
workshop_sidebar (row + 16×16 ics#, plotted with
plot_small_icon), workshop_window (k_module_info + ops
registration), main.c (View menu Cmd-6 + renumber), CMakeLists
(software_module.c, software_layout.c).
Rung 4 — the host module¶
SoftwareModel (drives software.list paging, holds entries) +
SoftwareModuleView: SwiftUI Table — nothing here needs to be a drag
source, so no NSTableView — with the domain picker, the same columns,
and a Launch button sending launch with the entry's path. Model tests
mirror ProcessesModelTests (paging, entry decode, running/off
rendering); the listing decode is pinned by the rung-2 fixtures. Until
this rung exists, the host console's local /swpage [domain] [cursor]
drove the family live; it is still there, now behind the / that marks
every host-local verb (see architecture.md).
Failure honesty¶
- A domain folder that does not exist is an empty listing, not an error.
- A sweep stopped by
catChangedError the tick cap says so (placard / listingmore), never silently truncates. launchrefusals carry the reason to the console, the placard, and the log — three surfaces, one string.verson a damaged fork reports, closes, and moves on.- The host serving empty non-apps domains is a declared limit in the contract, not an accident.
Tests and verification¶
- Host
ccnatives: layout geometry; any pure row-formatting logic worth pinning (thefourcc/size text already ship insidesoftware.c— if they grow, split them theconn_fieldsway). GuestWireFixtureTests:software.listingfixtures, including a MacRoman high byte and anoff+runningcombination.- Host suite: SoftwareModel paging + HostSoftware serving.
- Mutation check the new guards at least once each: break the FSSpec compare and watch the running tag lie; drop a required listing field and watch conformance name it.
- Emulator pass per rung (the rung-0 flow: clone VM, push, drive the
console, screenshot). Metal pass before any rung is called done:
sw,vers, sweep pacing under a live transfer, and the page — the PowerBook is where the disk is honest.
What lands when¶
- Rung 1 — one commit-able arc: iterator refactor +
vers+ running tags, console-verified in the emulator,sw/launchmetal-run re-checked (they are still unwatched on the 1400c). - Rung 2 — contract + both serving halves + fixtures; host console can already page by asking twice, before any page exists.
- Rung 3 — the guest page, an afternoon of geometry over working verbs if the doctrine held.
- Rung 4 — the host page, likewise.
Anything cut lands in open-issues.md under broken/unverified — the
ledger, not the spec, tracks what actually happened.