feat(settings): finish the Applications pane and fix its wire contract - #102
Conversation
The verify pass this issue asked for found the Applications/Devices pane in
worse shape than "some actions missing". Storing real settings on the test
account and re-probing every route exposed that the read/write half had never
worked: the DTOs were written from the gap definition before any populated
payload existed, and five of those guesses were wrong. Nothing caught it
because nothing in production consumed them — only `deregisterDevice` was ever
wired to a view.
What the live probe (2026-09-16) actually showed:
* `PUT` requires `baseVersion` in the body. Without it the server answers
400 outright, so every settings write this app could make was broken.
The whole family is compare-and-set: a stale version answers 409 and
writes nothing.
* Devices carry `deviceName` and `isDefault`. The decoder looked for
`name`/`deviceLabel` and `isMainWorkstation`/`isMain`, so every row would
have rendered as its raw UUID with no main-workstation badge, silently.
* `PATCH` accepts `deviceName` and `isDefault` — and says so when you get
it wrong. Both shipped mutations, rename and promote, were sending fields
the server rejects with a 400.
* `POST` and `PATCH` wrap the device in a `device` key. Decoding it bare
threw a decoding error on a successful 200.
* `bootstrap` returns one document plus a `source` tag, not the
shared/device pair the DTO modelled. Every field decoded to empty, always.
So the DTOs are rewritten against captured payloads rather than tightened, and
the speculative key aliases are gone: they never matched anything, and keeping
them would hide the next mismatch just as well as they hid these.
On top of that the three documented actions with no UI at all are now built —
inspecting the shared and per-machine documents with their last-updated and
size, copying a machine's settings to shared, and deleting the shared
settings. Each confirmation states the real consequence, because neither
"removal takes the machine's own settings with it" nor "copy replaces rather
than merges" is inferable from the button.
Two semantics are driven by evidence rather than assumption. Removing the main
workstation returns `promotedDeviceId` naming the successor, so the client
applies it instead of guessing; only when the server names nobody and the
reconcile read also fails does the badge fall back to "unknown", which is the
one case where showing the old flag would assert something now false.
And this Mac is registered on first open of the pane but never again: `POST`
is an upsert keyed on `deviceId`, so re-registering would overwrite
`deviceName` and quietly undo the user's rename on every visit.
The 409's `current` document is deliberately not plumbed through. Surfacing it
means teaching `APIClient` to carry typed error bodies, which touches every
endpoint's error path; re-reading costs one request and the blob is opaque, so
there is nothing to merge field-by-field anyway.
`appSettingsKey` is unchanged, as required — it is the namespace every stored
setting lives under.
Refs #56
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two files conflicted. One was a true duplicate, and resolving it by keeping both sides would have compiled and been wrong. StubAPIClient.swift: this branch and #87 (now on dev) independently added request- body recording to the test stub, for the same reason, within days of each other. Near-identical code, different local names, different comments. The resolution is one implementation, not two — but each side had a piece the other lacked, so it is not simply "take one". dev's encode also handles `.raw` bodies, which this branch's did not; strictly more complete, so that is the one kept. This branch's `bodyJSON` accessor is what its own AppSettingsServiceTests read (four call sites), and dev has no equivalent, so that is kept too. Both motivating examples stay in the comment because both are real shipped defects: a schema sent as a string where the server demands an object (#85), and `{"name":…}` where the server demands `{"deviceName":…}` (#56). In both cases every path-and-method assertion passed the whole time, which is the argument for recording bodies at all. SettingsRootView.swift auto-merged, and this time the auto-merge is genuinely correct — verified rather than assumed, because the same file merged cleanly and wrongly on #92. All eleven tabs carry a `.tag(SettingsTab...)`, including this branch's rename of Devices to Applications, which kept its `.devices` tag. Under `TabView(selection:)` an untagged tab cannot be selected at all, so the check is worth making by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflict resolvedMerged current
|
Closes out the verify-then-build in #56. Do not merge before #98 — this branch has
fix/xcode27-document-ambiguitymerged into it so the App-target gate could run; that commit vanishes from the diff once #98 lands ondev.What the verify pass found
The pane exists, but "some of the six may already be present" turned out to understate the problem in one direction and overstate it in the other. Three actions had UI that could not work, and three had no UI at all.
PATCH {"isMainWorkstation":true}→ 400PATCH {"name":…}→ 400promotedDeviceIdand never refetchedPUTwithoutbaseVersion→ 400All six Kit route builders were already present, which is why this read as further along than it was. The routes were right; the bodies and the decoders were not. Nothing caught it because nothing in production consumed the settings half —
deregisterDevicewas the only method wired to a view, so the broken paths were never exercised.Probe results (2026-09-16, test account, verbatim)
First-run state, exactly as #56 describes — preserved, not "fixed":
Then I stored real settings — the main verification value in the issue — and re-probed. Five DTO guesses were wrong:
1. Writes require
baseVersion; the whole family is compare-and-set.Note the document is returned bare,
scopeis"account"(the OpenAPI example says"user"— live wins), and the write replaces:sidebarWidthwas gone after a PUT carrying onlytheme. That is the direct evidence behind the destructive copy-to-shared confirmation.2. Devices use
deviceName/isDefault, notname/isMainWorkstation.The shipped decoder looked for
name/deviceLabelandisMainWorkstation/isMain. None exist — so every row would have displayed its raw UUID and the Main badge would never have appeared, silently, because the fields were optional.hasDeviceSettingsis new and list-only.3.
PATCHnames its own contract when you get it wrong.Promoting beta demoted alpha on the next list — promote/demote confirmed server-side.
4.
POST/PATCHwrap the device.-> 200 {"device":{…}}, not bare, so register and rename both threw a decoding error on success. (POST answers 200, not the spec's 201.)5.
bootstrapis one document plus asourcetag, not theshared+devicepair the DTO modelled — so it decoded to empty on every launch. The full precedence chain, each branch driven live:Remove semantics — better than the issue assumed. The server names the successor, so there is nothing to guess:
Also confirmed: removing a machine deletes its per-device settings (re-registered the same id, its settings read back 404) and leaves shared settings untouched.
DELETEon shared is idempotent —{"deleted":true}then{"deleted":false}, never a 404.One trap found while checking whether to auto-register this Mac:
POST …/devicesis an upsert keyed ondeviceId— re-posting an existing id does not duplicate the row, it overwritesdeviceName. Registering unconditionally on pane load would have reset the machine's name to its hostname every visit, silently undoing any rename. Registration is therefore guarded on absence.Test account left clean —
{"devices":[]}and 404 on shared, the exact state I found it in. Everything written (probe-mac-alpha,probe-mac-beta,probe-idem, and the account document) was deleted.What was built
baseVersionis a non-optional initialiser parameter, since no valid write exists without one. Speculative key aliases removed: they never matched anything, and keeping them would hide the next mismatch as well as they hid these. NewAppDeviceEnvelope,DeleteDeviceResponse,DeleteAppSettingsResponse,AppSettingsSource.AppSettingsDocumentcarriesversionso callers can legally write;copyDeviceSettingsToSharedreads the destination's version (not the source's — independent counters); 409 →AppSettingsError.versionConflict; a 404 from the device-settings write →.deviceNotRegistered, because there it means the device is missing, not the document./help/app-settings.StubAPIClientnow records request bodies. A wrong body is as breaking as a wrong path and far quieter: this surface shipped for weeks sending{"name":…}while every path-and-method assertion passed.Not gated — this section is free, per the issue.
Deliberately NOT built
currentdocument.APIClientreduces every non-2xx body to a message string, so surfacingcurrentmeans teaching it typed error payloads — a change to every endpoint's error path, far outside this pane. Re-reading costs one request and the blob is opaque, so there is nothing to merge field-by-field. Conflicts surface as "reload and try again".UserDefaultsstate. feat(settings): finish the Applications pane - main workstation, rename, remove, copy to shared #56 scopes this in "if the pane's read/write half is solid — otherwise split it out." It was not solid; it was non-functional. Now that it works this is worth its own issue, on top of a foundation that has been exercised against the live API.Gate (actual output)
ContractTestswere skipped, not run — the live suite is rate-limited from this session's recon. Every other number above was observed.DevicesViewModelTestsis 26 tests covering the required quartet: happy (rename / promote / remove / copy-to-shared / delete-shared / inspect round-trip), invalid (promote a device that no longer exists; re-register guard), upstream-failure (remove succeeds but refetch fails → row goes, badge reads unknown, no error banner), boundary (exactly one device removed; first-run 404s map to empty).Refs #56